From f0410d592d1b32f810437cd82d93b96a2581ae71 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 13:39:00 +0800 Subject: [PATCH 001/153] feat(web): permission presets and approval answering for the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web host now composes the sandboxed product path (sandbox-local + sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and permission on top); BootHostOptions.sandbox carries the deployment defaults (workspace-write + ask). createApiProxy owns the approval pending registry: a ctx.approval ask becomes an answerable approval/requested mux frame with a stable rpcId, replayed verbatim on every mux open until settled; respond routes by the echoed rpcId, validates the ApprovalResponsePayload audit correlation, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled. session.permissions / session.setPermission project ctx.permission into a protocol-owned PermissionOption select; idle switches are held last-write-wins and flushed into the next prompted turn (the ACP bridge's anchoring pattern). The shared hasOpenTurn fold moved to dsh-session, deduplicating the private copies in user-approval, the ACP bridge, and the proxy. Client, per the designer draft: a pending approval takes over the composer (ApprovalPanel replaces the InputBar — amber strip, justification headline, paired command, one-shot refuse/allow, keyed by rpcId so a queued second approval remounts live; the resolved frame restores the composer); the sidebar session row shows an amber waiting-approval dot that outranks the running ring (manager-tracked approvalId set, idempotent under mux-open replays, cleared per connection generation, lit for uninstantiated sessions too); the permission selector is a composer bottom-row chip over an invisible native select, with a presentation-only title-case transform (workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The connection fixture mirrors the host behavior for keyless browser acceptance. --- ...7-23-web-permission-and-approval.i18n.yaml | 6 + .../2026-07-23-web-permission-and-approval.md | 33 +++ ...26-07-23-web-permission-and-approval.zh.md | 33 +++ apps/web/tests/smoke-fixture.e2e.ts | 38 ++- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 10 +- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 12 +- docs/persistence-catalog.md | 6 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 62 +++- .../client/connection/src/client/index.ts | 2 +- packages/client/connection/tests/fake-api.ts | 8 + .../client/connection/tests/fixture.spec.ts | 57 ++++ packages/client/runtime/src/client/index.ts | 10 +- .../runtime/src/client/sessions/lineage.ts | 9 +- .../runtime/src/client/sessions/manager.ts | 31 +- .../runtime/src/client/sessions/service.ts | 3 + .../runtime/src/client/sessions/session.ts | 25 ++ packages/client/runtime/tests/fake-api.ts | 9 + packages/client/runtime/tests/manager.spec.ts | 39 +++ packages/client/runtime/tests/session.spec.ts | 24 ++ packages/client/ui-conversation/README.md | 5 +- .../ui-conversation/src/client/apply.ts | 29 +- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/chat/PendingCard.module.css | 18 +- .../src/client/chat/PendingCard.tsx | 23 +- .../src/client/contract/slots.ts | 68 ++++- .../client/skeleton/ApprovalPanel.module.css | 107 +++++++ .../src/client/skeleton/ApprovalPanel.tsx | 71 +++++ .../src/client/skeleton/ConversationRoot.tsx | 4 +- .../src/client/skeleton/InputBar.tsx | 7 +- .../skeleton/PermissionSelect.module.css | 49 ++++ .../src/client/skeleton/PermissionSelect.tsx | 88 ++++++ .../tests/apply-inject.spec.tsx | 30 ++ .../tests/chat-branch-tails.spec.tsx | 6 +- .../tests/chat-stats-bash-sample.spec.tsx | 6 +- .../tests/chat-toolview-slot.spec.tsx | 9 +- .../ui-conversation/tests/chat-view.spec.tsx | 13 +- .../tests/skeleton-branches.spec.tsx | 4 + .../ui-conversation/tests/skeleton.spec.tsx | 2 + packages/client/ui-sidebar/README.md | 2 +- .../client/ui-sidebar/src/client/Rows.tsx | 6 +- packages/client/ui-sidebar/src/client/tree.ts | 3 + .../client/ui-sidebar/tests/apply.spec.tsx | 2 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 15 + packages/client/ui-sidebar/tests/tree.spec.ts | 2 + .../client/ui-trajectory/tests/views.spec.tsx | 2 + packages/core/session/src/index.ts | 19 ++ packages/core/session/tests/session.spec.ts | 12 + packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 + .../host/apiproxy/src/api/sessions.schema.ts | 31 +- packages/host/apiproxy/src/api/sessions.ts | 35 +++ packages/host/apiproxy/src/fetch/client.ts | 8 + packages/host/apiproxy/src/fetch/handler.ts | 4 + .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 10 +- packages/host/runtime/README.md | 8 +- packages/host/runtime/package.json | 9 +- packages/host/runtime/src/api-proxy.ts | 195 ++++++++++++- packages/host/runtime/src/boot.ts | 41 ++- .../runtime/tests/api-proxy-approval.spec.ts | 275 ++++++++++++++++++ .../tests/api-proxy-permission.spec.ts | 152 ++++++++++ packages/host/runtime/tsconfig.json | 19 +- packages/ui/acp/src/index.ts | 15 +- packages/ui/user-approval/src/index.ts | 17 +- pnpm-lock.yaml | 25 +- 69 files changed, 1744 insertions(+), 139 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md create mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx create mode 100644 packages/host/runtime/tests/api-proxy-approval.spec.ts create mode 100644 packages/host/runtime/tests/api-proxy-permission.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.i18n.yaml new file mode 100644 index 0000000000..4d686731fe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.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 +2026-07-23-web-permission-and-approval.md: cd402a039e55e7a24a038055dab5793aa0d08438 +2026-07-23-web-permission-and-approval.zh.md: ce4964789bc94a0962796bb2f5fbf1a94e8f5145 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md new file mode 100644 index 0000000000..cd402a039e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI permission presets and approval answering + +Status: implemented + +English | [中文](2026-07-23-web-permission-and-approval.zh.md) + +## Problem + +The web host booted an unconfined agent: `bootHost` composed `dsh-bash-local` and `dsh-fs-local`, so every web session ran with full file access, no approval channel, and no permission control — while the ACP composition had shipped the complete sandboxed product path (sandbox provider + policy home + confined bash/fs + approval + presets) for months. The web wire contract had already reserved the seats — `approval/requested`/`approval/resolved` mux frames, `POST /api/respond` with `ApprovalResponsePayload`, client-side `pendingBuffers` — but the host `respond` was a stub, no answerer bridged `ctx.approval` to the stream, no RPC exposed the permission select, and the PendingCard rendered approvals as visible-but-unanswerable. + +## Decision + +The web host composes the same sandboxed product path as the acp-agent composition: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`, with `BootHostOptions.sandbox` supplying the deployment defaults (`mode`, default `workspace-write`; `approvalPolicy`, default `ask`). + +`createApiProxy` owns the approval pending registry. Its `approval/request` waterfall answerer reads the approval id from the session's just-appended `approval/asked` audit event (an ask with no audit event is a foreign channel and delegates), mints one stable rpcId per question, broadcasts the answerable `approval/requested` frame to every open mux stream, and replays still-pending frames verbatim on each mux open — the refresh-recovery baseline the contract already promised. `respond` routes by the echoed rpcId, validates `ApprovalResponsePayload` with the existing zod schema, cross-checks the payload's audit correlation against the routed entry, resolves the answerer, and broadcasts `approval/resolved`; the ask's abort signal withdraws the question as `cancelled`. + +The permission select rides two new unary RPCs, `session.permissions` and `session.setPermission`, projecting `ctx.permission` into a protocol-owned `PermissionOption` DTO (the ACP bridge precedent: each protocol owns its presentation shape). A permission-less composition serves an empty select and clients hide the control. Idle switches are held last-write-wins in a proxy-side pending map and flushed on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay; the shared `hasOpenTurn` fold moved to `dsh-session` and replaced the private copies in `dsh-user-approval`, the ACP bridge, and the proxy. + +Client-side, `Session` gained `permissions` and `setPermission`, and approval answering rides the runtime's `PendingWait` carrier. Per the designer draft, a pending approval takes over the composer: `ApprovalPanel` registers as a selector-routed entry of the conversation-declared `conversation.composer` chain (the ui-question pattern), replacing the InputBar with the justification headline, the paired command, and one-shot refuse/allow buttons; the `PendingApproval` domain face in ui-conversation's contract owns the `ApprovalResponsePayload` wire encoding over the carrier, and the broadcast resolved frame settles the wait and restores the composer. Question placeholders stay in the message flow. The sidebar mirrors the blocked state with an amber warning dot that outranks the running ring: the manager tracks per-session outstanding approvalIds (idempotent under mux-open replays, cleared per connection generation so the reopen replay is authoritative) rather than reading Session instances, so the dot lights for sessions never instantiated. The composer's bottom-row chip hosts the `PermissionSelect` control fed through the conversation inject face. The connection fixture mirrors the host: its resident approval is answerable once, and its permission select persists per session. + +## Alternatives considered + +**Reuse the ACP `session/set_config_option` shape on the web wire.** Rejected: the web contract's unary method registry (`RpcMethodMap` + per-method zod schemas) is its own dialect; a generic config-option surface would bypass the compiler-locked schema table for one select. A dedicated method pair keeps both sides derivable from the signature. + +**A session event for pending approvals instead of a proxy-side registry.** Rejected: approval requests are transient interaction state, not durable session data — the `approval/asked`/`decided` audit pair already logs the durable half. Persisting requested frames would re-ask dead questions on replay. + +**Registering the answerer only when a mux subscriber exists.** Rejected: the pending entry must survive client disconnects (refresh recovery is the point), so the registry outlives any one stream; a subscriber-gated answerer would fail asks closed during a reload window. + +**Optimistic card removal on click.** Rejected: the broadcast resolved frame is the truth; removing on click would hide a question that a rejected receipt or transport failure left standing. The panel disables its buttons locally and re-arms them on failure instead. + +## Consequences + +Web sessions now start confined (`workspace-write` + `ask` by default) and a sandbox-denial escalation reaches the browser as an answerable card; the deployment can widen or narrow the default through `BootHostOptions.sandbox` without touching the assembly. Question answering shipped separately through the same registry pattern (ui-question over the question pending table). The permission select reads once per mount; live refresh from another client's switch is deferred. Coverage: proxy registry and permission RPC unit suites, session-object and fixture unit suites, and the keyless web smoke exercises the fixture-mode approval answer and preset switch in a real browser. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md new file mode 100644 index 0000000000..ce4964789b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-permission-and-approval.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Web UI 权限预设与审批应答 + +Status: implemented + +[English](2026-07-23-web-permission-and-approval.md) | 中文 + +## 问题 + +Web 承载层启动的是一个不受限的 agent(智能体):`bootHost` 组合了 `dsh-bash-local` 与 `dsh-fs-local`,因此每个 Web 会话都以完整文件访问权限运行,既无审批通道,也无权限管控——而 ACP 组合早在数月前就已交付完整的沙箱化产品路径(沙箱提供方 + 策略归属 + 受限的 bash/fs + 审批 + 预设)。Web 协议契约其实早已预留了对应位置——`approval/requested`/`approval/resolved` 的 mux 帧、携带 `ApprovalResponsePayload` 的 `POST /api/respond`、client 侧的 `pendingBuffers`——但 host 的 `respond` 只是一个 stub,没有应答者把 `ctx.approval` 桥接到流上,没有 RPC 暴露权限选择,PendingCard 把审批渲染成可见却无法应答的样子。 + +## 决策 + +Web 承载层组合与 acp-agent 相同的沙箱化产品路径:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 与 `dsh-permission`,由 `BootHostOptions.sandbox` 提供部署默认值(`mode`,默认 `workspace-write`;`approvalPolicy`,默认 `ask`)。 + +`createApiProxy` 拥有审批 pending 注册表。它的 `approval/request` waterfall(瀑布式事件)应答者从会话刚追加的 `approval/asked` 审计事件中读取审批 id(没有审计事件的 ask 属于外部通道,予以委托),为每个问题 mint 一个稳定的 rpcId,向每个打开的 mux 流广播可应答的 `approval/requested` 帧,并在每次 mux 打开时原样重放仍处于 pending 的帧——这正是契约早已承诺的刷新恢复基线。`respond` 按回显的 rpcId 路由,用既有的 zod schema 校验 `ApprovalResponsePayload`,将载荷的审计关联与所路由的条目交叉核对,解析应答者,并广播 `approval/resolved`;ask 的中断信号会以 `cancelled` 撤回该问题。 + +权限选择依托两个新的一元 RPC,`session.permissions` 与 `session.setPermission`,把 `ctx.permission` 投影为一个由协议拥有的 `PermissionOption` DTO(沿用 ACP bridge 的先例:每个协议拥有自己的呈现形状)。无权限的组合提供空的选择项,client 隐藏该控件。空闲期的切换以后写胜出(last-write-wins)的方式保存在 proxy 侧的 pending map 中,并在 `agent/prompt-submit` 时冲刷,因为旋钮事件必须保持轮次内闭合以支持持久回放;共享的 `hasOpenTurn` 折叠迁入 `dsh-session`,取代了 `dsh-user-approval`、ACP bridge 与 proxy 中各自的私有副本。 + +在 client 侧,`Session` 新增了 `permissions` 与 `setPermission`,审批应答则依托运行时的 `PendingWait` 载体。按照设计师草稿,处于 pending 的审批会接管 composer:`ApprovalPanel` 注册为由会话声明的 `conversation.composer` 链中一个按选择器路由的条目(即 ui-question 模式),以理由标题、配对的命令与一次性的拒绝/允许按钮取代 InputBar;ui-conversation 契约中的 `PendingApproval` 领域面拥有 `ApprovalResponsePayload` 在该载体上的协议编码(wire encoding),广播的 resolved 帧使该等待落定并恢复 composer。问题占位符仍留在消息流中。侧边栏用一枚琥珀色警示圆点同步呈现这一阻塞状态,且其优先级高于表示运行中的圆环:manager 跟踪每个会话尚未解决的 approvalId(对 mux 打开时的回放幂等,并按连接代次清除,以保证重开后的回放才是权威依据),而非读取 Session 实例,因此从未实例化过的会话也能点亮该圆点。composer 底行的 chip 经会话注入面挂载 `PermissionSelect` 控件。连接 fixture(测试前置数据)与 host 保持一致:它的常驻审批可应答一次,其权限选择项按会话持久保存。 + +## 曾考虑的替代方案 + +**在 Web 协议上复用 ACP 的 `session/set_config_option` 形状。** 不予采纳:Web 契约的一元方法注册表(`RpcMethodMap` + 逐方法的 zod schema)是它自成一体的方言;一个通用的 config-option 接口会为一个选择项绕开编译期锁定的 schema 表。一对专用方法让两侧都能从签名推导得出。 + +**用一个会话事件承载 pending 审批,而非 proxy 侧注册表。** 不予采纳:审批请求是瞬态的交互状态,而非持久的会话数据——`approval/asked`/`decided` 审计对已经记录了持久的那一半。持久化 requested 帧会在回放时重新问出已经作废的问题。 + +**仅在存在 mux 订阅者时才注册应答者。** 不予采纳:pending 条目必须在 client 断连后依然存活(刷新恢复正是要点所在),因此注册表的生命周期长于任何单个流;一个受订阅者门控的应答者,会让在重载窗口期间关闭的 ask 落空。 + +**点击即乐观移除卡片。** 不予采纳:广播的 resolved 帧才是真相;点击即移除会隐藏一个因拒绝回执或传输失败而仍然悬置的问题。面板改为在本地禁用其按钮,并在失败时重新启用。 + +## 后果 + +Web 会话现在从受限状态启动(默认 `workspace-write` + `ask`),一次沙箱拒绝的升级会以可应答的卡片形式抵达浏览器;部署方可以通过 `BootHostOptions.sandbox` 放宽或收紧默认值,无需触动装配。问题应答已通过同一注册表模式单独交付(ui-question 基于问题 pending 表)。权限选择在每次挂载时读取一次;来自另一个 client 切换的实时刷新暂缓实现。覆盖情况:proxy 注册表与权限 RPC 的单元测试套件、会话对象与 fixture 的单元测试套件,以及无密钥 Web 冒烟测试在真实浏览器中演练 fixture 模式的审批应答与预设切换。 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0726d14c8b..423e42f14d 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -280,9 +280,45 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter') await composer.waitFor({ state: 'detached' }) + // The composer does not fall back to the InputBar yet: fx-alpha's resident + // approval is still pending, so the approval takeover elects next (the + // approval scenario below answers it and watches the InputBar return). + await page.locator('[data-approval-key]').waitFor({ state: 'visible', timeout: 10_000 }) + }) + + it('answers the composer-takeover approval panel; the InputBar returns on the resolved frame', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-approval-answer')) + // fx-alpha carries the resident pending approval; the session is already + // selected by the previous scenario. With the question settled, the + // approval owns the composer slot — no textarea is mounted. + const panel = page.locator('[data-approval-key]') + await panel.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await panel.getByText('等待审批').count()).toBe(1) + expect(await page.locator('textarea').count()).toBe(0) + // Sidebar mirrors the blocked state: the session row shows the amber + // warning dot in place of the running ring. + expect(await page.locator('[role="treeitem"] [data-state="warning"]').count()).toBeGreaterThan(0) + await panel.getByRole('button', { name: '允许一次' }).click() + // The broadcast resolved frame removes the panel and restores the + // composer; the sidebar dot clears with it. + await panel.waitFor({ state: 'detached', timeout: 5000 }) const restoredInput = page.locator('textarea[placeholder]') - await restoredInput.waitFor() + await restoredInput.waitFor({ state: 'visible', timeout: 5000 }) expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') + await expect.poll(() => page.locator('[role="treeitem"] [data-state="warning"]').count(), { timeout: 5000 }).toBe(0) + }) + + it('switches the permission preset through the composer select', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-permission-select')) + const select = page.locator('select').filter({ hasText: 'Workspace Write' }).first() + await select.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await select.inputValue()).toBe('workspace-write') + await select.selectOption('danger-full-access') + // The control disables during the round trip and adopts the confirmed + // value; host-side persistence across re-reads is covered by the fixture + // unit suite (fixture.spec.ts permissions/setPermission). + await expect.poll(() => select.inputValue(), { timeout: 5000 }).toBe('danger-full-access') + await expect.poll(() => select.isDisabled(), { timeout: 5000 }).toBe(false) }) it('stayed clean: no page errors across the whole load chain', () => { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..75b9f56eed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1719,7 +1719,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/ui/user-approval/src/index.ts:198`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:183`](../packages/ui/user-approval/src/index.ts) ## `@deepseek-ai/dsh-web` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..1602ee6b1e 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -420,7 +420,7 @@ Ask composed answerers for one decision. Return an outcome to claim the request Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) · [ApprovalService](../core-data-structures/approval.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/ui/user-approval/src/index.ts:30`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-approval/src/index.ts) ## `commands/*` @@ -572,7 +572,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:98`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -593,7 +593,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:108`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -616,7 +616,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:120`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -637,7 +637,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:130`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5858799e7c..ca7ab31b9d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -246,7 +246,7 @@ async request(req: ApprovalRequest): Promise Types: [ApprovalOutcome](../core-data-structures/approval.md) · [ApprovalRequest](../core-data-structures/approval.md) -Source: [`packages/ui/user-approval/src/index.ts:213`](../../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:198`](../../packages/ui/user-approval/src/index.ts) ## `ctx.bash` — `BashExecutor` (abstract seam) @@ -1246,7 +1246,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:624`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..291bae5122 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -14,7 +14,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:365`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | | `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:315`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:262`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), `runtime` | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:207`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:330`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode) | @@ -24,17 +24,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:352`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) | -| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | +| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp), `runtime` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:98`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:108`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:120`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:130`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8c1d815bc0..7a1d5ecfb6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -106,7 +106,7 @@ Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src Types: [CallId](core-data-structures/core.md) -Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:45`](../packages/ui/user-approval/src/index.ts) #### `approval/decided` — log-only @@ -122,7 +122,7 @@ Source: [`packages/ui/user-approval/src/index.ts:44`](../packages/ui/user-approv } ``` -Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:56`](../packages/ui/user-approval/src/index.ts) #### `approval/policy` — log-only @@ -138,7 +138,7 @@ Source: [`packages/ui/user-approval/src/index.ts:55`](../packages/ui/user-approv 'approval/policy': { policy: ApprovalPolicy } ``` -Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approval/src/index.ts) +Source: [`packages/ui/user-approval/src/index.ts:68`](../packages/ui/user-approval/src/index.ts) ### `assistant/*` diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c7e1ed5c68..033d801243 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -7,7 +7,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 7585ca9fba..5ca929eb2d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -299,10 +299,19 @@ export function createFixtureApi(): ApiProxy { let nextSession = 1 let nextRpc = 1 const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ + /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ const pendingApprovalRpcId = mint() + const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] + /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ + let approvalPending = true const pendingQuestionRpcId = mint() let questionPending = true + /** Per-session permission preset (fixture mirror of the host permission select). */ + const permissionValues = new Map() + const PERMISSION_OPTIONS = [ + { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, + { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + ] const fixtureQuestions: Extract['questions'] = [ { id: 'harness-profile', @@ -522,6 +531,24 @@ export function createFixtureApi(): ApiProxy { } return ok(request, { accepted: true as const }) }, + permissions: (request) => { + const { sessionId: id } = request.payload + if (summaryOf(id) === undefined) { + return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + return ok(request, { options: PERMISSION_OPTIONS, currentValue: permissionValues.get(id) ?? 'workspace-write' }) + }, + setPermission: (request) => { + const { sessionId: id, value } = request.payload + if (summaryOf(id) === undefined) { + return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + if (!PERMISSION_OPTIONS.some(option => option.value === value)) { + return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) + } + permissionValues.set(id, value) + return ok(request, { currentValue: value }) + }, }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions: 1 }), @@ -539,14 +566,16 @@ export function createFixtureApi(): ApiProxy { const title = titleFrameOf(s.sessionId, logs.get(s.sessionId) ?? []) if (title !== undefined) conn.push({ rpcId: mint(), payload: title }) } - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: 'fx-approval-1' as MuxFrame extends never ? never : Extract['approvalId'], - toolName: 'dangerous_tool', reason: 'fixture 常驻占位审批(可见不可答)', - }, - }) + if (approvalPending) { + conn.push({ + rpcId: pendingApprovalRpcId, + payload: { + type: 'approval/requested', sessionId: sid('fx-alpha'), + approvalId: pendingApprovalId, + toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + } if (questionPending) { conn.push({ rpcId: pendingQuestionRpcId, @@ -584,6 +613,19 @@ export function createFixtureApi(): ApiProxy { }, }, respond(message: ClientResponse): Promise { + // Same routing discipline as the host: rpcId first, then the payload's + // audit correlation; a settled or unknown id is not-pending. + if (message.rpcId === pendingApprovalRpcId) { + if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const value = message.result.value as { approvalId?: unknown; outcome?: unknown } + if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approvalPending = false + emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) + return Promise.resolve({ accepted: true }) + } if (!questionPending || message.rpcId !== pendingQuestionRpcId) { return Promise.resolve({ accepted: false, reason: 'not-pending' }) } @@ -633,6 +675,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.history': return this.api.sessions.history(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.cancel': return this.api.sessions.cancel(request) + case 'session.permissions': return this.api.sessions.permissions(request) + case 'session.setPermission': return this.api.sessions.setPermission(request) case 'host.describe': return this.api.host.describe(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index b017d1c9e2..fa2ddf9bdc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,7 +15,7 @@ import { WebApiClient } from './web-api-client.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, PermissionOption, ToolEventView, ToolCallView, ToolResultView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index af5743bf9a..0bdef3432d 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -49,6 +49,12 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPermissions: (payload: unknown) => + Promise> = + () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) + + onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = + payload => Promise.resolve(ok({ currentValue: payload.value })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -65,6 +71,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), + setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..187869e42d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -255,6 +255,61 @@ describe('createFixtureApi', () => { })).toEqual({ accepted: true }) }) + it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + const api = createFixtureApi() + // Discover the resident approval's stable rpcId from the mux baseline. + const abort = new AbortController() + const seen: { rpcId: string; frame: MuxFrame }[] = [] + const consuming = (async () => { + for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) + })() + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) + }) + const requested = seen.find(s => s.frame.type === 'approval/requested') + if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') + const approvalId = requested.frame.approvalId + + // Routed but malformed answers. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // The real answer settles the question and broadcasts resolved. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) + .toEqual({ accepted: true }) + await vi.waitFor(() => { + expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) + }) + // Settled: a duplicate answer is late, and a fresh mux open replays nothing. + expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + await consuming + const abort2 = new AbortController() + const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) + expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + }) + + it('permissions/setPermission mirror the host select: read, switch, validation', async () => { + const api = createFixtureApi() + const read = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) + expect(read.result).toMatchObject({ ok: true, value: { currentValue: 'workspace-write' } }) + const switched = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'danger-full-access' })) + expect(switched.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) + const reread = await api.sessions.permissions(req({ sessionId: sid('fx-alpha') })) + expect(reread.result).toMatchObject({ ok: true, value: { currentValue: 'danger-full-access' } }) + // Validation: ghost session and unknown value. + const ghostRead = await api.sessions.permissions(req({ sessionId: sid('fx-ghost') })) + expect(ghostRead.result.ok).toBe(false) + const ghostSwitch = await api.sessions.setPermission(req({ sessionId: sid('fx-ghost'), value: 'workspace-write' })) + expect(ghostSwitch.result.ok).toBe(false) + const unknown = await api.sessions.setPermission(req({ sessionId: sid('fx-alpha'), value: 'nope' })) + expect(unknown.result.ok).toBe(false) + }) + it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) @@ -345,6 +400,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) + expect((await client.sessions.permissions({ sessionId: id })).result.ok).toBe(true) + expect((await client.sessions.setPermission({ sessionId: id, value: 'danger-full-access' })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) }) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 33097d4573..65f378684d 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -38,7 +38,15 @@ export type { // PendingWait is a value export: tests construct fixture waits directly. export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' -export type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client' + +/** The permission select material as the object layer serves it to UI plugins. */ +export interface PermissionSelect { + /** Switchable presets plus (when derived) the current-only `custom`. */ + options: { value: string; name: string; description?: string }[] + /** The effective current value (`custom` when knobs match no preset). */ + currentValue: string +} // ---- Narrowed aliases (the single narrowing point of the slot type chain: // ui-slots/web-react stay generic and dependency-inverted; the client-tree diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index c6bd572ea7..fb6a85704b 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -9,7 +9,7 @@ export interface TitledSessionSummary extends SessionSummary { title?: string } -/** One flattened session-list row (summary + lineage indent depth). */ +/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -17,6 +17,8 @@ export interface SessionListEntry { running: boolean parentSessionId?: SessionId cwd?: string + /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ + waitingApproval: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ depth: number } @@ -25,9 +27,10 @@ export interface SessionListEntry { * summaries -> flat list with lineage indentation (pure; roots by updatedAt * desc, DFS children in the same order, orphans degrade to roots). * @param summaries - the host's session.list items. + * @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false). * @returns display rows in render order. */ -export function flattenLineage(summaries: readonly TitledSessionSummary[]): SessionListEntry[] { +export function flattenLineage(summaries: readonly TitledSessionSummary[], waitingApproval?: ReadonlySet): SessionListEntry[] { const byId = new Map() for (const s of summaries) byId.set(s.sessionId, s) @@ -54,7 +57,7 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[]): Sess return } visited.add(s.sessionId) - out.push({ ...s, depth }) + out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth }) const kids = children.get(s.sessionId) if (kids === undefined) return kids.sort(byUpdatedDesc) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index b65935a97d..f13600f037 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -36,6 +36,11 @@ export class SessionManager { * drop-and-backfill path; replayed and cleared on instantiation. Bounded per session (these * frames are low-frequency; overflow drops oldest) and dropped on session-removed (audit S7). */ private readonly pendingBuffers = new Map[]>() + /** Outstanding approval questions per session, keyed by approvalId (idempotent under mux-open + * replays of the same requested frame). Manager-owned rather than read off Session instances + * because the sidebar must light up for sessions never instantiated. Cleared per connection + * generation — the reopen replay re-adds still-pending questions — and on session-removed. */ + private readonly waitingApprovals = new Map>() private readonly titleSnapshots = new Map() private summaries: SessionSummary[] = [] private listState: 'idle' | 'loading' | 'error' = 'idle' @@ -186,6 +191,22 @@ export class SessionManager { this.notifier.markDirty() } } + // List-level waiting-approval bit (the sidebar amber dot): tracked here for + // every session, instantiated or not; approvalId keys make replays idempotent. + if (frame.type === 'approval/requested') { + let ids = this.waitingApprovals.get(frame.sessionId) + if (ids === undefined) this.waitingApprovals.set(frame.sessionId, ids = new Set()) + if (!ids.has(frame.approvalId)) { + ids.add(frame.approvalId) + this.notifier.markDirty() + } + } else if (frame.type === 'approval/resolved') { + const ids = this.waitingApprovals.get(frame.sessionId) + if (ids !== undefined && ids.delete(frame.approvalId)) { + if (ids.size === 0) this.waitingApprovals.delete(frame.sessionId) + this.notifier.markDirty() + } + } const session = this.sessions.get(frame.sessionId) if (session === undefined) { // Approval/question frames never hit history: buffer for replay on instantiation; @@ -232,6 +253,7 @@ export class SessionManager { this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId) this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation + this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone this.titleSnapshots.delete(frame.sessionId) this.notifier.markDirty() return @@ -254,6 +276,12 @@ export class SessionManager { /** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */ handleConnected(): void { + // Approvals resolved while disconnected send no frame: drop the bits and + // let the mux-open replay re-add every still-pending question. + if (this.waitingApprovals.size > 0) { + this.waitingApprovals.clear() + this.notifier.markDirty() + } void this.refreshList() for (const session of this.sessions.values()) void session.resync() } @@ -265,13 +293,14 @@ export class SessionManager { ? summary : { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) } }) - const fresh = flattenLineage(merged) + const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys())) const items = fresh.map((entry) => { const prev = this.entryCache.get(entry.sessionId) if ( prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.title === entry.title && prev.depth === entry.depth + && prev.waitingApproval === entry.waitingApproval ) return prev this.entryCache.set(entry.sessionId, entry) return entry diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index d8a6f05762..daf81d102f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -32,6 +32,8 @@ export interface SessionSummary { cwd?: string parentId?: SessionId running: boolean + /** An approval question is pending on this session (sidebar amber-dot state). */ + waitingApproval: boolean updatedAt: number } @@ -310,6 +312,7 @@ export class SessionsService { id: entry.sessionId, displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), running: entry.running, + waitingApproval: entry.waitingApproval, updatedAt: entry.updatedAt, ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6b773e0903..741716f44a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -128,6 +128,31 @@ export class Session implements ObservableSnapshot { return result } + /** + * Read the permission select (options + effective current value). + * @returns the select material, or the error branch on failure. + */ + async permissions(): Promise> { + try { + return (await this.api.sessions.permissions({ sessionId: this.sessionId })).result + } catch (error) { + return transportError(error) + } + } + + /** + * Switch the permission preset. + * @param value - a preset value advertised by {@link Session.permissions} (never `custom`). + * @returns the confirmed current value, or the error branch on failure. + */ + async setPermission(value: string): Promise> { + try { + return (await this.api.sessions.setPermission({ sessionId: this.sessionId, value })).result + } catch (error) { + return transportError(error) + } + } + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ open(): Promise { if (this.openState === 'open') return Promise.resolve() diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..5ec6f4737f 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -52,6 +52,13 @@ export class FakeApiClient implements IApiClient { onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onPermissions: (payload: unknown) => + Promise> = + () => Promise.resolve(ok({ options: [], currentValue: 'custom' })) + + onSetPermission: (payload: { sessionId: SessionId; value: string }) => Promise> = + payload => Promise.resolve(ok({ currentValue: payload.value })) + onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -68,6 +75,8 @@ export class FakeApiClient implements IApiClient { this.record('session.history', payload, this.onHistory(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), + permissions: (payload: unknown) => this.record('session.permissions', payload, this.onPermissions(payload)), + setPermission: (payload: { sessionId: SessionId; value: string }) => this.record('session.setPermission', payload, this.onSetPermission(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index d37ef6dbce..64c01247e2 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -281,3 +281,42 @@ describe('connected generation', () => { }) }) }) + +describe('waiting-approval list bit', () => { + it('lights on requested, survives replay duplicates, and clears on resolved — without instantiation', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + // Mux-open replay of the same question (same approvalId) is idempotent. + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'ap1' as never, outcome: 'allowed-once' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + }) + + it('clears only when the last outstanding question resolves; session-removed drops the bit', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleMuxEnvelope({ rpcId: 'r1' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a1' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'r2' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a2' as never, toolName: 'rm' } }) + manager.handleMuxEnvelope({ rpcId: 'rx' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a1' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleMuxEnvelope({ rpcId: 'ry' as never, payload: { type: 'approval/resolved', sessionId: S1, approvalId: 'a2' as never, outcome: 'rejected' as never } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + // Removed sessions drop their bit outright. + manager.handleMuxEnvelope({ rpcId: 'r3' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'a3' as never, toolName: 'rm' } }) + manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(manager.getListSnapshot().items).toHaveLength(0) + }) + + it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1 } }) + manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } }) + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true) + manager.handleConnected() // resolved-while-disconnected questions send no frame + expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false) + }) +}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..8678096856 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -669,3 +669,27 @@ describe('reference stability (the memo contract)', () => { expect(resolved.pending).toBe(after.pending) }) }) + +describe('permissions / setPermission', () => { + it('passes the select read and switch through with the session id', async () => { + const { api, session } = makeSession() + api.onPermissions = () => Promise.resolve(ok({ options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' })) + const read = await session.permissions() + expect(read.ok).toBe(true) + if (read.ok) expect(read.value.currentValue).toBe('workspace-write') + expect(api.callsOf('session.permissions')).toMatchObject([{ sessionId: SID }]) + + const switched = await session.setPermission('danger-full-access') + expect(switched.ok).toBe(true) + if (switched.ok) expect(switched.value.currentValue).toBe('danger-full-access') + expect(api.callsOf('session.setPermission')).toMatchObject([{ sessionId: SID, value: 'danger-full-access' }]) + }) + + it('folds transport failures into the error branch', async () => { + const { api, session } = makeSession() + api.onPermissions = () => Promise.reject(new Error('down')) + api.onSetPermission = () => Promise.reject(new Error('down')) + expect((await session.permissions()).ok).toBe(false) + expect((await session.setPermission('x')).ok).toBe(false) + }) +}) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ebd7474298..75967f46a4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -4,6 +4,8 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. +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: the session row's amber warning dot (a manager-tracked `waitingApproval` list bit, lit for uninstantiated sessions too) outranks the running ring until the question resolves. Question placeholders remain in the message flow as display-only PendingCards while ui-question owns the answering takeover. The composer's bottom-row chip mounts the permission-preset select (`PermissionSelect`), fed by the injected `permissions`/`setPermission` callbacks over the object layer's session RPCs; empty options (a permission-less host) hide the control, and the derived `custom` value renders as current-only. + Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. 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`/`openDetails`) 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). @@ -26,4 +28,5 @@ None; this package neither assembles nor sends a provider request. - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. -- **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **The permission select reads once per mount** — a host-side preset change from another client surfaces only after a session re-select; live knob-event-driven refresh is deferred. +- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..f17e32d1ee 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -15,12 +15,13 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ViewTab } from './contract/views.ts' import type { - ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected, + ApprovalWait, ChatViewInjected, ComposerChainProps, ConversationInjected, DetailsInjected, EmptyStateInjected, } from './contract/slots.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' @@ -37,6 +38,11 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat return conversation } +/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */ +function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null { + return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null +} + /** * Client plugin body. * @param ctx - client root context. @@ -104,10 +110,31 @@ export function apply(ctx: Context): void { }) }, open: (target: SessionId) => { sessions.open(target) }, + permissions: async () => { + const result = await sessions.manager.get(sessionId).permissions() + // Empty options = permission-less host composition: hide the control + // rather than show an empty select (deployment shape, not an error). + if (!result.ok || result.value.options.length === 0) return null + return result.value + }, + setPermission: async (value) => { + const result = await sessions.manager.get(sessionId).setPermission(value) + return result.ok ? result.value.currentValue : null + }, } }, }, ConversationRoot) + // The approval takeover: a selector-routed entry of the chain this package + // just declared (the ui-question registration pattern; the entry lives here + // because approval answering is core conversation UX, not an optional tool). + // Zero business face — data and verbs both ride the matched carrier. + // priority 1: question takeovers (default 0) win when both kinds are + // pending — a question is a conversation the model is waiting on, while an + // approval only blocks one tool call; answering the question first cannot + // strand the approval (it re-elects the moment the question resolves). + slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel) + // The chat view: first entry of the ring this package just declared. // Declaring the keyed toolview hole here is claiming it: ChatView is the // only component authorized to render per-tool rows. Shares the chat diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 8023acddde..23f822ee19 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -254,7 +254,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl ))} )} - {pending.map((item) => )} + {/* Approvals take over the composer (ApprovalPanel); only question + placeholders remain in the flow. */} + {pending.filter((item) => item.kind === 'question').map((item) => )} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.module.css b/packages/client/ui-conversation/src/client/chat/PendingCard.module.css index 2318c88c94..cb17b4387c 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.module.css +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.module.css @@ -1,4 +1,4 @@ -/* Amber pending strip (approval waiting = warn semantic, figma state colors). */ +/* Amber pending strip (question waiting = warn semantic, figma state colors). */ .card { margin: 6px 0; @@ -13,19 +13,3 @@ font-weight: 500; color: var(--dsw-alias-label-primary); } - -.mono { - font-family: var(--ds-font-family-code); -} - -.reason { - margin-top: 4px; - font-size: 12px; - color: var(--dsw-alias-label-secondary); -} - -.hint { - margin-top: 6px; - font-size: 11px; - color: var(--dsw-alias-label-tertiary); -} diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx index b6825aed9a..c5a5ead161 100644 --- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx @@ -1,6 +1,7 @@ -// PendingCard: approval/question placeholder card (visible, not answerable — -// the composer-takeover approval panel is a P-II item; wire pending semantics -// already exist so the flow must show them). +// PendingCard: question pending placeholder in the message flow (visible +// while the question composer owns the takeover slot elsewhere). Approvals +// do not render here: they take over the composer (skeleton ApprovalPanel) +// per the designer draft. import { memo } from 'react' import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client' @@ -8,24 +9,14 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PendingCard.module.css' export interface PendingCardProps { - item: PendingInteraction + item: Extract } export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) { return (
- {item.kind === 'approval' ? ( - <> -
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
} - - ) : ( - <> -
等待回答({item.payload.questions.length} 题)
- - - )} -
请在原客户端处理(web 端作答后续里程碑提供)
+
等待回答({item.payload.questions.length} 题)
+
) }) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index ffbc13ff59..fa8a493135 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -11,7 +11,7 @@ * here. */ import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PendingInteraction, PendingWait, PermissionSelect, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { createChatStore } from '../stores.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -113,6 +113,10 @@ export interface ConversationInjected { stop(): void /** Navigate to another session (breadcrumb ancestors). */ open(id: SessionId): void + /** Read the permission select (options + effective current value); null hides the control. */ + permissions(): Promise + /** Switch the permission preset; resolves the confirmed value, or null on failure (caller keeps the old value). */ + setPermission(value: string): Promise } /** @@ -132,6 +136,68 @@ export type ConversationSlotProps = PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'> & PropsStore & ConversationInjected +/** The pending approval carrier the owner dispatches into the composer chain. */ +export type ApprovalWait = PendingWait<'approval'> + +/** + * Approval domain face over the carrier (the ui-question PendingQuestion + * pattern): render identity and question material forwarded transparently; + * answer owns the wire encoding — the ApprovalResponsePayload value shape + * with the audit correlation the host reconciles — and turns a rejected + * carrier receipt into a thrown error. Minted per carrier via useMemo. + */ +export class PendingApproval { + /** + * @param wait - the runtime carrier for one pending approval question. + */ + constructor(private readonly wait: ApprovalWait) {} + + /** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */ + get key(): string { + return this.wait.key + } + + /** The tool the question is about (headline fallback), forwarded from the carrier payload. */ + get toolName(): string { + return this.wait.payload.toolName + } + + /** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */ + get reason(): string | undefined { + return this.wait.payload.reason + } + + /** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */ + get callId(): string | undefined { + return this.wait.payload.callId + } + + /** + * Deliver the user's decision; a rejected carrier receipt throws. Panel + * removal stays frame-driven: the broadcast `approval/resolved` settles the + * wait and drops it from the pending list. + * @param outcome - the only two client-answerable outcomes. + */ + async answer(outcome: 'allowed-once' | 'rejected'): Promise { + const receipt = await this.wait.respond({ + ok: true, + value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome }, + }) + if (!receipt.accepted) { + throw new Error(`approval response rejected: ${receipt.reason}`) + } + } +} + +/** + * Full approval-composer props: the framework runtime share (chain currency + + * session/global standard kit) plus the chain `matched` share — the entry's + * selector result, already narrowed to the approval carrier. No injected + * share: the carrier plus the domain face above carry the whole behavior + * surface; the paired command line derives from useSession in-component. + */ +export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait } + /** * Injected share of the chat view entry: the two callbacks whose targets live * outside the view (layout orchestration; the session object layer). diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css new file mode 100644 index 0000000000..c3d8ef47bd --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.module.css @@ -0,0 +1,107 @@ +/* Composer-takeover approval panel (draft approval.png): the same floating + capsule footprint as the InputBar card, with an amber header band, the + justification headline, a muted command line, and right-aligned actions. + Warn semantics ride the alias state tokens; no hardcoded colors. */ + +/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */ +.root { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 32px 12px; +} + +.card { + overflow: hidden; + width: 100%; + max-width: 776px; + border: 1px solid var(--dsw-alias-state-warn-secondary); + border-radius: 20px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv2); +} + +/* Tinted full-width header band. */ +.strip { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--dsw-alias-state-warn-tertiary); + color: var(--dsw-alias-state-warn-primary); + font-size: 13px; + line-height: 18px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dsw-alias-state-warn-primary); +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 16px 14px; +} + +/* The model's justification is the panel's message, not a footnote. */ +.headline { + color: var(--dsw-alias-label-primary); + font-size: 15px; + font-weight: 500; + line-height: 24px; +} + +.command { + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 13px; + line-height: 20px; + word-break: break-all; +} + +.actionRow { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.allow, +.reject { + padding: 6px 16px; + border-radius: 10px; + font-size: 13px; + line-height: 18px; + cursor: pointer; +} + +.allow:disabled, +.reject:disabled { + opacity: 0.5; + cursor: default; +} + +/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped + always-allow button). */ +.allow { + border: none; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-primary-foreground); +} + +/* Secondary: quiet outline. */ +.reject { + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + background: transparent; + color: var(--dsw-alias-label-secondary); +} + +.reject:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + border-color: transparent; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx new file mode 100644 index 0000000000..7b8d52a6ec --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ApprovalPanel.tsx @@ -0,0 +1,71 @@ +// ApprovalPanel: the composer-takeover approval prompt (designer draft +// approval.png), registered as a selector-routed entry of the +// conversation-declared composer chain. While an approval question is +// pending, this panel occupies the composer slot in place of the InputBar: +// an amber "Waiting for approval" strip on the card top, the model's +// justification as the headline, the paired command in muted code text, and +// a right-aligned refuse/allow action row. One-shot: the buttons disable +// after a click and the panel leaves (the InputBar returns) on the broadcast +// resolved frame. The draft's "Always allow this type" is deferred with +// grant storage. + +import { useMemo, useState } from 'react' +import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client' +import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */ +export function commandOf(call: RunningToolCall | undefined): string | undefined { + if (call === undefined) return undefined + try { + const args = JSON.parse(call.argsRaw) as Record + return typeof args.command === 'string' ? args.command : undefined + } catch { + // Unparseable model args: the panel still renders, just without the command line. + return undefined + } +} + +/** + * Composer takeover boundary: mints the domain face on the carrier's stable + * identity and remounts the flow per request key, so the one-shot answered + * latch never leaks to the next pending approval. + * @param props - the selector-matched pending approval carrier plus the framework standard kit. + * @returns The approval prompt for this request. + */ +export function ApprovalPanel(props: ApprovalComposerProps) { + const approval = useMemo(() => new PendingApproval(props.matched), [props.matched]) + const command = props.useSession(s => commandOf( + approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId))) + return +} + +function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) { + // Local one-shot latch: the panel leaves only when the resolved frame + // lands; until then the buttons must not re-fire. An answer failure + // (rejected receipt / transport) re-arms them for retry. + const [answered, setAnswered] = useState(false) + const answer = (outcome: 'allowed-once' | 'rejected'): void => { + setAnswered(true) + void pending.answer(outcome).catch(() => { setAnswered(false) }) + } + return ( +
+
+
等待审批
+
+
{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}
+ {command !== undefined &&
{command}
} +
+ + +
+
+
+
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..d904c73b68 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { PermissionSelect } from './PermissionSelect.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -38,7 +39,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session export function ConversationRoot({ sessionId, useSession, useSessions, useStore, actions, renderSlot, renderSlotChain, - views, send, stop, open, + views, send, stop, open, permissions, setPermission, }: ConversationRootProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -68,6 +69,7 @@ export function ConversationRoot({ disabled={removed} error={error} variant="composer" + controls={} onDraftChange={actions.setDraft} onSend={(mode) => { send(draft, mode) }} onStop={stop} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 04d1dd867d..551ce70cac 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -30,6 +30,8 @@ export interface InputBarProps { placeholder?: string /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode + /** Host-wired access-mode control (the composer mounts the permission chip here); replaces the visual-only placeholder. */ + controls?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void @@ -56,7 +58,7 @@ const MODEL_OPTIONS: readonly SelectOption[] = [ ] export function InputBar({ - draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, + draft, running, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop, }: InputBarProps) { const empty = draft.trim() === '' const inputRef = useRef(null) @@ -177,7 +179,8 @@ export function InputBar({
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} + {/* The wired permission chip supersedes the visual-only Access placeholder. */} + {controls ?? renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css new file mode 100644 index 0000000000..dd5986992c --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -0,0 +1,49 @@ +/* Composer bottom-row permission chip (draft start.jpeg `Read-only ∨`): a + quiet text chip with a chevron; hover paints the standard interactive pill. + The native select is stretched invisibly over the chip so the platform + dropdown does the menu work — keyboard/AT semantics come free. */ + +.root { + position: relative; + display: inline-flex; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 20px; + pointer-events: none; /* the overlaid select owns the interaction */ +} + +.root:hover .chip { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + color: var(--dsw-alias-label-caption); +} + +/* Invisible native select stretched over the chip: real menu, zero drawing. */ +.select { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + border: none; + cursor: pointer; +} + +.select:disabled { + cursor: default; +} + +.root:has(.select:disabled) .chip { + opacity: 0.5; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx new file mode 100644 index 0000000000..a32e2af037 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -0,0 +1,88 @@ +// PermissionSelect: the composer bottom-row permission chip (draft +// start.jpeg's `Read-only ∨` control). Options and the current value load on +// mount from the injected permissions() callback; empty options +// (permission-less host composition) render nothing. The visible chip is +// presentation only — an invisible native select stretched over it owns the +// menu and interaction. A switch disables the control until the host +// confirms, then adopts the confirmed value (`custom` is shown as the current +// value but never offered as a target — the host already omits it from +// switchable options; a stale-select failure restores the previous value). + +import { useEffect, useRef, useState } from 'react' +import type { PermissionSelect as PermissionSelectData } from '@deepseek-ai/dsh-client-runtime/client' +import css from './PermissionSelect.module.css' + +/** + * Display transform: kebab-case machine names render as title-case labels + * (`workspace-write` → `Workspace Write`). Presentation-only — the wire + * vocabulary and the host's advertised names are untouched; a host-configured + * name that is not kebab-case (contains spaces or uppercase) passes through. + */ +function displayName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} + +export interface PermissionSelectProps { + /** Read the select material; null hides the control. */ + permissions: () => Promise + /** Switch the preset; resolves the confirmed value, or null on failure. */ + setPermission: (value: string) => Promise +} + +export function PermissionSelect({ permissions, setPermission }: PermissionSelectProps) { + const [data, setData] = useState(null) + const [switching, setSwitching] = useState(false) + // Unmount guard: the load/switch promises outlive a session switch's remount. + const aliveRef = useRef(true) + useEffect(() => { + aliveRef.current = true + void permissions().then((loaded) => { + if (aliveRef.current) setData(loaded) + }) + return () => { + aliveRef.current = false + } + }, [permissions]) + + if (data === null) return null + + const onChange = (value: string): void => { + if (value === data.currentValue) return + setSwitching(true) + const previous = data + setData({ ...data, currentValue: value }) + void setPermission(value).then((confirmed) => { + if (!aliveRef.current) return + setSwitching(false) + if (confirmed === null) setData(previous) + else setData({ ...previous, currentValue: confirmed }) + }) + } + + const current = data.options.find(option => option.value === data.currentValue) + + return ( + + ) +} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index edd9f7d54d..aa06d6853e 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -61,6 +61,10 @@ async function bench() { () => Promise.resolve({ ok: true, value: { accepted: true } })), cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>( () => Promise.resolve({ ok: true, value: { accepted: true } })), + permissions: vi.fn<() => Promise<{ ok: boolean; value?: { options: { value: string; name: string }[]; currentValue: string }; error?: { code: string; message: string } }>>( + () => Promise.resolve({ ok: true, value: { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } })), + setPermission: vi.fn<() => Promise<{ ok: boolean; value?: { currentValue: string }; error?: { code: string; message: string } }>>( + () => Promise.resolve({ ok: true, value: { currentValue: 'danger-full-access' } })), } const scopes = new Map() const mint = (id: SessionId): Context => { @@ -143,6 +147,32 @@ describe('conversation slot inject surface', () => { expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) }) + it('permissions/setPermission thread the object layer; empty options and failures fold to null', async () => { + const b = await bench() + const { injected } = b.conversationSurface(ROOT) + expect(await injected.permissions()).toMatchObject({ currentValue: 'workspace-write' }) + expect(await injected.setPermission('danger-full-access')).toBe('danger-full-access') + // Empty options (permission-less host) and the error branch both hide the control. + b.sessionFake.permissions.mockResolvedValueOnce({ ok: true, value: { options: [], currentValue: 'custom' } }) + expect(await injected.permissions()).toBeNull() + b.sessionFake.permissions.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } }) + expect(await injected.permissions()).toBeNull() + b.sessionFake.setPermission.mockResolvedValueOnce({ ok: false, error: { code: 'bad-request', message: 'x' } }) + expect(await injected.setPermission('nope')).toBeNull() + }) + + it('registers the approval takeover on the composer chain: routing selector, no inject face', async () => { + const b = await bench() + const entry = b.slots.entries('conversation.composer')[0]! + expect(entry.inject).toBeUndefined() + // The selector narrows the chain currency: approval wait in → that wait; none → null. + const select = entry.select as (owner: { interactions: readonly { kind: string }[] }) => unknown + const approval = { kind: 'approval' } + expect(select({ interactions: [{ kind: 'question' }, approval] })).toBe(approval) + expect(select({ interactions: [{ kind: 'question' }] })).toBeNull() + expect(select({ interactions: [] })).toBeNull() + }) + it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => { const b = await bench() const { instance, injected } = b.conversationSurface(ROOT) 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 be50356185..95565add35 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -44,11 +44,11 @@ describe('MessageItem arms', () => { }) describe('small branch tails', () => { - it('PendingCard approval reason renders when present', () => { + it('PendingCard renders the question count', () => { const view = render( - ['payload'], vi.fn())} />, + ['payload'], vi.fn())} />, ) - expect(view.getByText('careful')).toBeTruthy() + expect(view.getByText(/等待回答(1 题)/)).toBeTruthy() }) it('AssistantMarkdown single-line reasoning summary skips the newline cut', () => { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 4d3383b2d1..383bc92883 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -123,8 +123,8 @@ describe('bash sample row', () => { return createSnapshotStore({ ids: [ROOT, CHILD], byId: { - [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 }, - [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 }, + [ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, waitingApproval: false, updatedAt: 0 }, + [CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, waitingApproval: false, updatedAt: 0 }, }, current: undefined, } as SessionListState) @@ -158,7 +158,7 @@ describe('bash sample row', () => { const orphan = 'late-child' as SessionId store.update((d) => { d.ids.push(orphan) - d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 } + d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, waitingApproval: false, updatedAt: 0 } }) const view = render() expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull() diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 5d2b3408a2..2fa9224a95 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -75,7 +75,14 @@ async function bench(nodes: ToolResultNode[]) { const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } ctx.provide('sessions', { list, - manager: { get: () => ({ loadOlder: vi.fn() }) }, + manager: { + get: () => ({ + loadOlder: vi.fn(), + // The mounted PermissionSelect loads on mount; empty options hide the control. + permissions: vi.fn(() => Promise.resolve({ ok: true, value: { options: [], currentValue: 'custom' } })), + setPermission: vi.fn(() => Promise.resolve({ ok: false, error: { code: 'bad-request', message: 'unused', details: { issues: [] } } })), + }), + }, scope: () => ({ get: () => scoped }), cell: (id: string) => (id === SID ? cell : undefined), create: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3f1db55199..314080f9be 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -342,12 +342,17 @@ describe('ChatView', () => { expect(lv.getByText('载入历史…')).toBeTruthy() }) - it('pending interactions render placeholder cards', () => { + it('question waits render placeholder cards; approvals leave the flow (composer takeover)', () => { const h = makeHarness({ - pending: [new PendingWait('approval', RpcId('r1'), SID, - { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())], + pending: [ + new PendingWait('approval', RpcId('r1'), SID, + { approvalId: 'ap1', toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn()), + new PendingWait('question', RpcId('r2'), SID, + { questions: [{ id: 'q1', question: '选择' }] } as PendingWait<'question'>['payload'], vi.fn()), + ], }) const view = render() - expect(view.getByText(/等待审批/)).toBeTruthy() + expect(view.getByText(/等待回答(1 题)/)).toBeTruthy() + expect(view.queryByText(/等待审批/)).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 4eb70ea39f..9231211a47 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -82,6 +82,8 @@ describe('ConversationRoot branches', () => { send={vi.fn()} stop={vi.fn()} open={open} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) return { view, open, chat } @@ -141,6 +143,8 @@ describe('ConversationRoot branches', () => { send={vi.fn()} stop={vi.fn()} open={vi.fn()} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) expect(view.getByTestId('view-body')).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a598803a25..f7cce649e7 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -221,6 +221,8 @@ describe('ConversationRoot', () => { send={send} stop={stop} open={open} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />) return { ui, chat, send, stop, open, renderSlot } } diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index c165455b1a..af56b1ac7d 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired. +- **State dots have three live data states (running/amber approval-waiting/none)** — the done/error sources arrive with notifications; the four-color primitive is already wired. - **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred. - **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host. diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx index 53f8bbe14f..541e2c9388 100644 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ b/packages/client/ui-sidebar/src/client/Rows.tsx @@ -102,7 +102,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: { ) : } - {row.running && } + {/* Waiting-approval (amber) outranks the running ring: the session is + blocked on the user, which is the more actionable fact. */} + + {row.waitingApproval ? : row.running && } + {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-sidebar/src/client/tree.ts index 021c7e9276..a37a61cf75 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-sidebar/src/client/tree.ts @@ -38,6 +38,8 @@ export interface SessionRow { hasChildren: boolean expanded: boolean running: boolean + /** An approval question is pending (amber warning dot outranks the running ring). */ + waitingApproval: boolean updatedAt: number } @@ -159,6 +161,7 @@ function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boo hasChildren, expanded, running: s.running, + waitingApproval: s.waitingApproval, updatedAt: s.updatedAt, } } diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 44fdae18f8..1757a9623a 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -23,7 +23,7 @@ async function bench() { await ctx.plugin(SlotsService).await() const list = createSnapshotStore({ ids: [sid('a')], - byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, + byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, waitingApproval: false, updatedAt: 1 } }, current: undefined, }) const sessions = { diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 71416aef36..437f2398b1 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -31,6 +31,7 @@ interface SummaryInit { cwd?: string parentId?: string running?: boolean + waitingApproval?: boolean updatedAt?: number } @@ -40,6 +41,7 @@ function summary(init: SummaryInit): SessionSummary { title: init.title ?? init.id, displayTitle: init.title ?? init.id, running: init.running ?? false, + waitingApproval: init.waitingApproval ?? false, updatedAt: init.updatedAt ?? 0, } if (init.cwd !== undefined) s.cwd = init.cwd @@ -290,4 +292,17 @@ describe('SidebarRoot', () => { expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull() }) + + it('waiting-approval shows the amber warning dot and outranks the running ring', () => { + mount( + summary({ id: 'blocked', title: 'blocked one', cwd: '/p', running: true, waitingApproval: true, updatedAt: 2 }), + summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 1 }), + ) + act(() => { fireEvent.click(screen.getByText('p')) }) + const blockedRow = screen.getByText('blocked one').closest('[role="treeitem"]')! + const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')! + expect(blockedRow.querySelector('[data-state="warning"]')).toBeTruthy() + expect(blockedRow.querySelector('[data-state="ongoing"]')).toBeNull() + expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy() + }) }) diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-sidebar/tests/tree.spec.ts index 037a2a82ac..79a8afefa0 100644 --- a/packages/client/ui-sidebar/tests/tree.spec.ts +++ b/packages/client/ui-sidebar/tests/tree.spec.ts @@ -15,6 +15,7 @@ interface SummaryInit { cwd?: string parentId?: string running?: boolean + waitingApproval?: boolean updatedAt?: number } @@ -23,6 +24,7 @@ function summary(init: SummaryInit): SessionSummary { id: sid(init.id), displayTitle: init.displayTitle ?? init.title ?? init.id, running: init.running ?? false, + waitingApproval: init.waitingApproval ?? false, updatedAt: init.updatedAt ?? 0, } if (init.title !== undefined) s.title = init.title diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 4818e67db5..d77c1cdf74 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -144,6 +144,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES send={vi.fn()} stop={vi.fn()} open={vi.fn()} + permissions={() => Promise.resolve(null)} + setPermission={() => Promise.resolve(null)} />, ) } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..e00babb648 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -59,6 +59,25 @@ export function findLastMessageTurnEnd( return latest } +/** + * Whether the log currently sits inside an open turn (a `turn/start` not yet + * closed by a `turn/end`). The turn is the durable log's commit/replay + * boundary: a bare event appended between turns is indistinguishable from a + * crash tail and silently dropped on reload, so writers of turn-enclosed + * events (approval audit pairs, permission/sandbox knob switches) gate on + * this fold and hold idle writes until the next turn opens. + * @param events - session events, or an owned suffix, to inspect. + * @returns true when the last turn boundary event is a `turn/start`. + */ +export function hasOpenTurn(events: readonly SessionEvent[]): boolean { + for (let index = events.length - 1; index >= 0; index -= 1) { + const type = (events[index] as SessionEvent).type + if (type === 'turn/start') return true + if (type === 'turn/end') return false + } + return false +} + declare module 'cordis' { interface Context { sessions: SessionStore diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..83a2fa1172 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -4,6 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { displayPromptContent, findLastMessageTurnEnd, + hasOpenTurn, SESSION_FORMAT_VERSION, Session, SessionEvent, @@ -91,6 +92,17 @@ describe('Session', () => { expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd) }) + it('reports an open turn only between turn/start and its turn/end', () => { + const session = new Session(SessionId('open-turn')) + expect(hasOpenTurn(session.events)).toBe(false) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(hasOpenTurn(session.events)).toBe(true) + session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(hasOpenTurn(session.events)).toBe(true) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(hasOpenTurn(session.events)).toBe(false) + }) + it('round-trips the coarse aborted turn outcome', () => { const session = new Session(SessionId('aborted')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1badfe65e8..9a2df36b47 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -24,6 +24,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `dsh-host-runtime` and is still a stub there. +- **`respond` is routed by host-side pending tables** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final here; the approval and question registries that make late/duplicate answers meaningful live in `dsh-host-runtime`. - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index c2fbb0d189..242c5f20a9 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -19,7 +19,7 @@ export interface ApiProxy { } // ---- Domain interfaces and payload entities ---- -export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts' +export type { HistoryEntry, PermissionOption, SessionsApi, SessionSummary } from './sessions.ts' export type { HostApi } from './host.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index b37cc062ff..6ada97d673 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -15,6 +15,8 @@ export interface RpcMethodMap { 'session.history': SessionsApi['history'] 'session.prompt': SessionsApi['prompt'] 'session.cancel': SessionsApi['cancel'] + 'session.permissions': SessionsApi['permissions'] + 'session.setPermission': SessionsApi['setPermission'] 'host.describe': HostApi['describe'] } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..4c3da79dc9 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -9,7 +9,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' -import type { HistoryEntry, SessionSummary } from './sessions.ts' +import type { HistoryEntry, PermissionOption, SessionSummary } from './sessions.ts' import type { ToolEventView } from './events.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ @@ -108,3 +108,32 @@ export const sessionCancelRequestSchema = z.object({ export const sessionCancelValueSchema = z.object({ accepted: z.literal(true), }) satisfies z.ZodType>> + +/** One permission select option (a preset table key, or the derived `custom`). */ +export const permissionOptionSchema = z.object({ + value: z.string(), + name: z.string(), + description: z.string().optional(), +}) satisfies z.ZodType> + +/** session.permissions request payload. */ +export const sessionPermissionsRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** session.permissions response value. */ +export const sessionPermissionsValueSchema = z.object({ + options: z.array(permissionOptionSchema), + currentValue: z.string(), +}) satisfies z.ZodType>> + +/** session.setPermission request payload. */ +export const sessionSetPermissionRequestSchema = z.object({ + sessionId: sessionIdSchema, + value: z.string(), +}) satisfies z.ZodType>> + +/** session.setPermission response value. */ +export const sessionSetPermissionValueSchema = z.object({ + currentValue: z.string(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..b2c56405e2 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -44,6 +44,21 @@ export interface SessionSummary { cwd?: string } +/** + * One selectable permission preset (or the derived `custom` state) as the + * client renders it. Protocol-owned DTO (the ACP bridge precedent: each + * protocol owns its presentation shape); the host projects it from + * `ctx.permission` without exposing that service's types on the wire. + */ +export interface PermissionOption { + /** The machine value (`session.setPermission` vocabulary): a preset table key, or `custom`. */ + value: string + /** The display label. */ + name: string + /** One user-facing sentence on what the value means. */ + description?: string +} + /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ export interface SessionsApi { /** Lists persisted sessions (updatedAt descending). v1 returns everything; cursor is a reserved seat, unimplemented. */ @@ -70,4 +85,24 @@ export interface SessionsApi { /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> + + /** + * Reads the session's permission select: every switchable preset plus the + * effective current value (`custom` when the knobs match no preset — shown, + * never a switch target). A host composed without the permission service + * returns empty options and `custom`; clients hide the control. + */ + permissions(request: RpcRequest<{ sessionId: SessionId }>): + Promise> + + /** + * Switches the session's permission preset. Mirrors the ACP bridge's + * turn-anchoring: inside an open turn the knob events append immediately; + * idle switches are held last-write-wins and flushed into the next prompted + * turn (approval-policy and sandbox-mode events must stay turn-enclosed for + * durable replay). A current-value echo is acknowledged without recording. + * Unknown values and a permission-less composition are bad-request. + */ + setPermission(request: RpcRequest<{ sessionId: SessionId; value: string }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 901cf7bd2a..79941033bd 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -19,7 +19,9 @@ import { sessionCreateValueSchema, sessionHistoryValueSchema, sessionListValueSchema, + sessionPermissionsValueSchema, sessionPromptValueSchema, + sessionSetPermissionValueSchema, } from '../api/sessions.schema.ts' /** @@ -44,6 +46,8 @@ export interface IApiClient { history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> + permissions(payload: RequestPayload<'session.permissions'>, signal?: AbortSignal): Promise>> + setPermission(payload: RequestPayload<'session.setPermission'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -66,6 +70,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.history', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), + permissions: (payload, signal) => this.callUnary('session.permissions', payload, signal), + setPermission: (payload, signal) => this.callUnary('session.setPermission', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 03b9f6500f..085238ac0f 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -19,7 +19,9 @@ import { sessionCreateRequestSchema, sessionHistoryRequestSchema, sessionListRequestSchema, + sessionPermissionsRequestSchema, sessionPromptRequestSchema, + sessionSetPermissionRequestSchema, } from '../api/sessions.schema.ts' import { hostDescribeRequestSchema } from '../api/host.schema.ts' @@ -43,6 +45,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, + 'session.permissions': { schema: sessionPermissionsRequestSchema, invoke: (api, r) => api.sessions.permissions(r) }, + 'session.setPermission': { schema: sessionSetPermissionRequestSchema, invoke: (api, r) => api.sessions.setPermission(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25af7e2f75..e39d8d553a 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -31,6 +31,8 @@ function scriptedApi(overrides: { history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), + permissions: r => ok(r, { options: [], currentValue: 'custom' }), + setPermission: r => ok(r, { currentValue: r.payload.value }), ...overrides.sessions, }, host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), ...overrides.host }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..5272c65b9e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -36,6 +36,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async permissions(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { options: [], currentValue: 'custom' } } } + }, + async setPermission(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { currentValue: request.payload.value } } } + }, }, host: { async describe(request) { @@ -75,11 +81,13 @@ describe('unary round trip (handler ⇄ client, no network)', () => { if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found') }) - it('covers create/prompt/cancel/describe passthrough', async () => { + it('covers create/prompt/cancel/permissions/setPermission/describe passthrough', async () => { const c = client() expect((await c.sessions.create({})).result.ok).toBe(true) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true) + expect((await c.sessions.permissions({ sessionId: 's' as never })).result.ok).toBe(true) + expect((await c.sessions.setPermission({ sessionId: 's' as never, value: 'workspace-write' })).result.ok).toBe(true) expect((await c.host.describe({})).result.ok).toBe(true) }) }) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..c1817d8c6f 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, the provider-neutral user-interaction service, and the sandboxed product path — `dsh-sandbox-local` + `dsh-sandbox-policy` behind the confined `dsh-bash-sandbox`/`dsh-fs-sandbox` families, with `dsh-user-approval` and `dsh-permission` on top), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -15,11 +15,15 @@ Which plugins mount and with what defaults is decided only here — shells must | `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. | | `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. | | `sessionTitleLlm` | disabled | `true` enables the 5-word / 10-CJK-character, 4,096-input-byte, 64-output-token, 60-second first-message policy; an explicit config overrides it. An omitted route inherits the logged main-request provider and model. | +| `sandbox.mode` | `'workspace-write'` | File-sandbox mode sessions start from (`ctx.sandboxPolicy` default; per-session switches ride `sandbox/mode` events). | +| `sandbox.approvalPolicy` | `'ask'` | Approval policy for sessions without an `approval/policy` override. | ## ApiProxy implementation notes Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position. +The proxy is also the approval channel for the agents this host owns: an ask through `ctx.approval` becomes an answerable `approval/requested` mux frame with a stable rpcId held in a pending table, replayed verbatim on every mux open until settled. `respond` routes by the echoed rpcId (approvals first, then questions), validates the `ApprovalResponsePayload` audit correlation at the wire boundary, resolves the answerer, and broadcasts `approval/resolved`; the ask's own abort signal withdraws the question as `cancelled`. `session.permissions`/`session.setPermission` project `ctx.permission` (empty select when not composed); idle switches are held last-write-wins and flushed into the next prompted turn on `agent/prompt-submit`, because knob events must stay turn-enclosed for durable replay (the ACP bridge's anchoring pattern). + ## Model Experience Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled. @@ -30,6 +34,6 @@ No main-request invalidation; when enabled, the auxiliary title request has its ## Known Limitations and Deferred Work -- **Question waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence. +- **Question and approval waits are process-memory state** — browser reconnects recover them, but a host process restart aborts the owning tool call instead of restoring the wait from persistence. - **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version. - **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..47327683b8 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -31,13 +31,17 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", @@ -63,6 +67,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^" diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 3792d17da3..4cf8af1d65 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -1,19 +1,31 @@ /** - * Host-side ApiProxy implementation. Signature discipline: unary takes the - * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. + * Host-side ApiProxy implementation. Unary methods, both streams, and the + * pending-interaction registries are real: the approval registry turns + * `ctx.approval` asks into answerable `approval/requested` mux frames and the + * question provider does the same for `ask_user_question`; both are answered + * through `respond` (routed by the echoed rpcId). Signature discipline: unary + * takes the narrow RpcRequest

and echoes request.rpcId on the RpcResponse. */ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +// Side-effect type imports: resolve `ctx.approval` / `ctx.get('permission')` +// without value dependencies on the seams (both are optional compositions here). +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-permission' import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, } from '@deepseek-ai/dsh-host-apiproxy/api' +import { approvalResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/approvals.schema' import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema' import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -183,6 +195,36 @@ interface ToolCallData { callId: string; name: string; arguments: string } /** The tool/result payload fields the presenter path reads. */ interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue } +/** + * One outstanding approval question: the stable server-request id, the frame + * material replayed to late mux subscribers, and the resolver that settles the + * answerer's promise back into `ctx.approval`. + */ +interface PendingApproval { + rpcId: RpcId + sessionId: SessionId + approvalId: ApprovalRequestId + toolName: string + callId?: CallId + reason?: string + resolve(outcome: ApprovalOutcome): void +} + +/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */ +function requestedFrame(pending: PendingApproval): RpcRequest { + return { + rpcId: pending.rpcId, + payload: { + type: 'approval/requested', + sessionId: pending.sessionId, + approvalId: pending.approvalId, + toolName: pending.toolName, + ...pending.callId === undefined ? {} : { callId: pending.callId }, + ...pending.reason === undefined ? {} : { reason: pending.reason }, + }, + } +} + /** One host-owned question wait, addressed by the stable server-request id. */ interface PendingQuestion { rpcId: RpcId @@ -283,6 +325,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() const pendingQuestions = new Map() + const pendingApprovals = new Map() const muxQueues = new Set>>() /** Send one transient frame to every connected mux consumer. */ @@ -341,6 +384,95 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, 'api-proxy: user-interaction provider') + // --- Approval pending registry ------------------------------------------ + // The proxy is the approval channel for every agent this host owns: an ask + // through `ctx.approval` becomes an answerable server-request on the mux + // stream (stable rpcId), settled by POST /api/respond. The entry survives + // client disconnects — mux-open replays still-pending requested frames with + // the same rpcId (the refresh-recovery baseline) — and withdraws on the + // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers. + if (ctx.get('approval') !== undefined) { + ctx.on('approval/request', (req, next) => { + // The audit pair `approval/asked` is already appended by the service + // before dispatch, but dispatch rides a microtask: parallel tool calls + // can append several asked events before any answerer runs. THIS + // request's event is therefore the newest asked event that is still + // undecided, unclaimed by another pending entry, and — when the ask + // names a call — carries the same callId. + const events = req.agent.session.events + const claimed = new Set() + for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId) + const decided = new Set() + let approvalId: ApprovalRequestId | undefined + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i] as SessionEvent + if (event.type === 'approval/decided') { + decided.add(event.data.id) + } else if (event.type === 'approval/asked') { + if (decided.has(event.data.id) || claimed.has(event.data.id)) continue + if (req.callId !== undefined && event.data.callId !== req.callId) continue + approvalId = event.data.id + break + } + } + // No asked event means the request bypassed the service's audit path — + // not this channel's question; delegate to the fail-closed default. + if (approvalId === undefined) return next() + const id = approvalId + return new Promise((resolve) => { + const settle = (outcome: ApprovalOutcome): void => { + /* v8 ignore next 3 -- defensive double-settle guard: respond() routes + through the pending table (a settled id is not-pending before it can + re-settle) and the first settle removes the abort listener, so no + reachable path settles twice; kept against future settle callers. */ + if (!pendingApprovals.delete(pending.rpcId)) return + req.signal?.removeEventListener('abort', onAbort) + broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome }) + // A cancelled ask was already settled by the service's own signal + // race, which discards this late resolution; resolving is a no-op + // there and keeps this promise from dangling forever. + resolve(outcome) + } + const onAbort = (): void => { settle('cancelled') } + const pending: PendingApproval = { + rpcId: RpcId(randomUUID()), + sessionId: req.agent.session.id, + approvalId: id, + toolName: req.toolName, + ...req.callId === undefined ? {} : { callId: req.callId }, + ...req.reason === undefined ? {} : { reason: req.reason }, + resolve: settle, + } + pendingApprovals.set(pending.rpcId, pending) + req.signal?.addEventListener('abort', onAbort, { once: true }) + const envelope = requestedFrame(pending) + for (const queue of muxQueues) queue.push(envelope) + }) + }) + } + + // --- Permission switch anchoring ---------------------------------------- + // Knob events (`permission/preset`, `sandbox/mode`, `approval/policy`) must + // be turn-enclosed for durable replay, so an idle switch is held here + // last-write-wins and flushed when the next prompted turn opens (the ACP + // bridge's pendingSwitches pattern; prompt-submit is inside the new turn but + // before prompt assembly, so the switch is visible to that turn's request). + const pendingSwitches = new Map() + ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) => { + const preset = pendingSwitches.get(agent.session.id) + if (preset !== undefined) { + pendingSwitches.delete(agent.session.id) + const presets = ctx.get('permission') + /* v8 ignore next -- a pending preset exists only if setPermission saw the + service; it cannot unmount between that and the next turn here. */ + if (presets !== undefined) presets.set(agent.session, preset) + } + return next() + }) + ctx.on('session/disposed', (session: Session) => { + pendingSwitches.delete(session.id) + }) + /** * Gate the cold path on the store: an id absent from it, or naming a legacy * log without a cwd (pre-release stance: not served, no compatibility), is @@ -468,6 +600,48 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro agent.cancel() return Promise.resolve(ok(request, { accepted: true as const })) }, + + async permissions(request) { + const { sessionId } = request.payload + const presets = ctx.get('permission') + // A permission-less composition advertises an empty select (client + // hides the control) rather than erroring — the control's absence is + // deployment shape, not a caller mistake. + if (presets === undefined) return ok(request, { options: [], currentValue: 'custom' }) + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const events = found.agent.session.events + const currentValue = pendingSwitches.get(sessionId) ?? presets.current(events) + const options = [ + ...presets.names.map(name => presets.optionOf(name)), + // `custom` echoes the current derived state but is never a target. + ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], + ] + return ok(request, { options, currentValue }) + }, + + async setPermission(request) { + const { sessionId, value } = request.payload + const presets = ctx.get('permission') + if (presets === undefined) { + return err(request, { code: 'bad-request', message: 'no permission service is composed on this host', details: { issues: [] } }) + } + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const agent = found.agent + // A current-value echo is acknowledged without recording a switch. + const current = pendingSwitches.get(sessionId) ?? presets.current(agent.session.events) + if (value === current) return ok(request, { currentValue: value }) + if (!presets.names.includes(value)) { + return err(request, { code: 'bad-request', message: `unknown permission value ${JSON.stringify(value)}`, details: { issues: [] } }) + } + // Turn-anchoring (the ACP bridge pattern): knob events must be enclosed + // by the durable log's turn boundary, so an idle switch is held + // last-write-wins and flushed into the next prompted turn. + if (hasOpenTurn(agent.session.events)) presets.set(agent.session, value) + else pendingSwitches.set(sessionId, value) + return ok(request, { currentValue: value }) + }, }, host: { @@ -499,6 +673,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }) } + // Refresh recovery: still-pending approval questions replay with their + // stable rpcId so a reconnecting client can still answer them. + for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending)) // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream // opened mid-turn) backscans the session's in-memory events instead. @@ -564,6 +741,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, respond(message: ClientResponse): Promise { + // Route by the echoed rpcId (the wire correlation): approvals first, + // then questions — the two registries share one id space of UUIDs. + const approval = pendingApprovals.get(message.rpcId) + if (approval !== undefined) { + if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) + const parsed = approvalResponsePayloadSchema.safeParse(message.result.value) + // The payload's audit correlation must match the entry the rpcId routed + // to — a mismatched answer is malformed, not merely late. + if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) { + return Promise.resolve({ accepted: false, reason: 'bad-response' }) + } + approval.resolve(parsed.data.outcome) + return Promise.resolve({ accepted: true }) + } const pending = pendingQuestions.get(message.rpcId) if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' }) if (!message.result.ok) { diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index c0960ab678..3417956f62 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -18,11 +18,17 @@ import TaskService from '@deepseek-ai/dsh-tasks' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' +import SandboxLocal from '@deepseek-ai/dsh-sandbox-local' +import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import SandboxBashExecutor from '@deepseek-ai/dsh-bash-sandbox' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import PermissionService from '@deepseek-ai/dsh-permission' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as toolTodo from '@deepseek-ai/dsh-tool-todo' import * as toolTasks from '@deepseek-ai/dsh-tool-tasks' -import FsLocal from '@deepseek-ai/dsh-fs-local' +import FsSandbox from '@deepseek-ai/dsh-fs-sandbox' import * as fsPolicy from '@deepseek-ai/dsh-fs-policy' import * as toolFs from '@deepseek-ai/dsh-tool-fs' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -73,6 +79,17 @@ export interface BootHostOptions { sessionTitle?: SessionTitleConfig /** Opt-in first-message model-title policy; `true` selects host defaults and an explicit config overrides them. */ sessionTitleLlm?: true | SessionTitleLlmConfig + /** + * Sandbox/approval composition knobs. The host always composes the confined + * bash + fs families over `ctx.sandboxPolicy` (the acp-agent composition); + * these fields choose the deployment defaults every session starts from. + */ + sandbox?: { + /** File-sandbox mode sessions start from (default `workspace-write`). */ + mode?: SandboxMode + /** Approval policy for sessions without an override (default `ask`). */ + approvalPolicy?: ApprovalPolicy + } /** * Default project directory for sessions created without an explicit cwd * (defaults to the host process working directory). A session's cwd is its @@ -131,16 +148,30 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) - await ctx.plugin(LocalBashExecutor, {}) + // The sandboxed product path (the acp-agent composition, sandbox Agent + // Note): per-platform runner provider, the shared policy home, the confined + // bash executor, and the approval seam its escalation asks through. Sessions + // start from the configured default mode; per-session switches ride the + // `sandbox/mode` / `approval/policy` events written by ctx.permission. + await ctx.plugin(SandboxLocal, {}) + await ctx.plugin(SandboxPolicy, { + mode: options.sandbox?.mode ?? 'workspace-write', + workspaceRoot: defaults.cwd, + }) + await ctx.plugin(SandboxBashExecutor, {}) + await ctx.plugin(ApprovalService, { policy: options.sandbox?.approvalPolicy ?? 'ask' }) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + // the agent-spine bundle) so web sessions get the same coding-agent tool // face; deviations are noted inline. await ctx.plugin(toolBash, {}) + // Presets over the two knobs (requires the confining executor + approval). + await ctx.plugin(PermissionService, {}) await ctx.plugin(toolTodo) await ctx.plugin(toolTasks, {}) // fs paths resolve against the host default project rather than the raw - // process cwd — the same source create() injects into session.cwd. - await ctx.plugin(FsLocal, { cwd: defaults.cwd }) + // process cwd — the same source create() injects into session.cwd. The + // sandboxed backend fences write/edit by the same policy as bash. + await ctx.plugin(FsSandbox, { cwd: defaults.cwd }) await ctx.plugin(fsPolicy) await ctx.plugin(toolFs, {}) await ctx.plugin(toolFsSearch, {}) diff --git a/packages/host/runtime/tests/api-proxy-approval.spec.ts b/packages/host/runtime/tests/api-proxy-approval.spec.ts new file mode 100644 index 0000000000..e0cb0c71e5 --- /dev/null +++ b/packages/host/runtime/tests/api-proxy-approval.spec.ts @@ -0,0 +1,275 @@ +/** + * Approval pending registry over the proxy: an ask through `ctx.approval` + * becomes an answerable `approval/requested` mux frame (stable rpcId, replayed + * verbatim on a later mux open), `respond` routes by the echoed rpcId and + * validates the audit correlation, and the ask's abort signal withdraws the + * question with a broadcast `cancelled`. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import type { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId as mintRpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(ApprovalService) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + return { ctx, api } +} + +/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */ +function agentOf(ctx: Context): Agent { + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + return { session } as unknown as Agent +} + +/** Open a mux stream and capture frames into an array (returns an on-demand waiter). */ +function openMux(api: ApiProxy, abort: AbortController): { frames: MuxFrame[]; envelopes: RpcRequest[]; waitFor(type: MuxFrame['type']): Promise } { + const frames: MuxFrame[] = [] + const envelopes: RpcRequest[] = [] + const waiters: { type: MuxFrame['type']; resolve: (frame: MuxFrame) => void }[] = [] + void (async () => { + for await (const envelope of api.events.mux({ rpcId: mintRpcId('t-mux'), payload: {} }, abort.signal)) { + frames.push(envelope.payload) + envelopes.push(envelope) + for (let i = waiters.length - 1; i >= 0; i -= 1) { + const waiter = waiters[i] as (typeof waiters)[number] + if (waiter.type === envelope.payload.type) { + waiters.splice(i, 1) + waiter.resolve(envelope.payload) + } + } + } + })() + return { + frames, + envelopes, + waitFor: (type) => { + const found = frames.find(frame => frame.type === type) + if (found !== undefined) return Promise.resolve(found) + return new Promise((resolve) => { waiters.push({ type, resolve }) }) + }, + } +} + +function requestedOf(frame: MuxFrame): Extract { + if (frame.type !== 'approval/requested') throw new Error(`expected approval/requested, got ${frame.type}`) + return frame +} + +/** Wait until the stream delivered `count` frames of `type` (bounded poll; waitFor only covers the first). */ +async function waitForCount(mux: { frames: MuxFrame[] }, type: MuxFrame['type'], count: number): Promise { + for (let i = 0; i < 200 && mux.frames.filter(frame => frame.type === type).length < count; i += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(mux.frames.filter(frame => frame.type === type).length).toBeGreaterThanOrEqual(count) +} + +function answer(rpcId: RpcId, sessionId: unknown, approvalId: ApprovalRequestId, outcome: 'allowed-once' | 'rejected'): Parameters[0] { + return { type: 'client-response', rpcId, result: { ok: true, value: { sessionId, approvalId, outcome } } } +} + +describe('approval pending registry', () => { + it('round-trips ask → requested frame → respond → outcome + resolved broadcast', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + + const asked = ctx.approval.request({ agent, toolName: 'bash', reason: 'sandbox escalation' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested).toMatchObject({ toolName: 'bash', reason: 'sandbox escalation', sessionId: agent.session.id }) + + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + const receipt = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'allowed-once' }) + + // The question settled: a duplicate answer is late, not re-decidable. + const dup = await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'rejected')) + expect(dup).toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('replays a still-pending requested frame (same rpcId) on a later mux open', async () => { + const { ctx, api } = await harness() + const first = new AbortController() + const firstMux = openMux(api, first) + const agent = agentOf(ctx) + const asked = ctx.approval.request({ agent, toolName: 'write' }) + const requested = requestedOf(await firstMux.waitFor('approval/requested')) + const firstEnvelope = firstMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + first.abort() + + // A fresh subscriber (refresh recovery) sees the same stable rpcId. + const second = new AbortController() + const secondMux = openMux(api, second) + const replayed = requestedOf(await secondMux.waitFor('approval/requested')) + const secondEnvelope = secondMux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(secondEnvelope.rpcId).toBe(firstEnvelope.rpcId) + expect(replayed.approvalId).toBe(requested.approvalId) + + const receipt = await api.respond(answer(secondEnvelope.rpcId, replayed.sessionId, replayed.approvalId, 'rejected')) + expect(receipt).toEqual({ accepted: true }) + await expect(asked).resolves.toBe('rejected') + second.abort() + }) + + it('rejects malformed and mismatched answers as bad-response, unknown ids as not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + void ctx.approval.request({ agent, toolName: 'bash' }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + // Unknown rpcId: not routed to any pending entry. + expect(await api.respond(answer(mintRpcId('ghost'), requested.sessionId, requested.approvalId, 'rejected'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + // Error-branch result: the client can only answer with a value. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Wrong audit correlation: the rpcId routed, but the payload disagrees. + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, 'other-approval' as ApprovalRequestId, 'rejected'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + // Malformed payload shape. + expect(await api.respond({ type: 'client-response', rpcId: envelope.rpcId, result: { ok: true, value: { nonsense: 1 } } })) + .toEqual({ accepted: false, reason: 'bad-response' }) + abort.abort() + }) + + it('withdraws the question on the ask signal: cancelled outcome, resolved broadcast, late answer not-pending', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + + cancel.abort() + await expect(asked).resolves.toBe('cancelled') + const resolved = await mux.waitFor('approval/resolved') + expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' }) + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: false, reason: 'not-pending' }) + abort.abort() + }) + + it('carries callId on the frame and ignores a late abort after the answer settled', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const cancel = new AbortController() + const asked = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-9' as never, signal: cancel.signal }) + const requested = requestedOf(await mux.waitFor('approval/requested')) + expect(requested.callId).toBe('call-9') + const envelope = mux.envelopes.find(e => e.payload.type === 'approval/requested') as RpcRequest + expect(await api.respond(answer(envelope.rpcId, requested.sessionId, requested.approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toBe('allowed-once') + // Late abort: the pending entry is gone; settle's delete-guard returns. + cancel.abort() + expect(mux.frames.filter(f => f.type === 'approval/resolved')).toHaveLength(1) + abort.abort() + }) + + it('pairs parallel asks by callId: each requested frame carries its own audit id', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + // Both asks append their approval/asked audit events before either + // answerer's microtask dispatch runs — the parallel tool-call window. + const askA = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-a' as never }) + const askB = ctx.approval.request({ agent, toolName: 'bash', callId: 'call-b' as never }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).callId === 'call-a') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).callId === 'call-b') as RpcRequest + // Each frame claimed the asked event with its own callId, not merely the newest. + const askedIdByCall = new Map(agent.session.events + .filter(event => event.type === 'approval/asked') + .map(event => [String(event.data.callId), event.data.id])) + expect(requestedOf(frameA.payload).approvalId).toBe(askedIdByCall.get('call-a')) + expect(requestedOf(frameB.payload).approvalId).toBe(askedIdByCall.get('call-b')) + // Answers route back to the right ask through the pairing. + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('gives parallel callId-less asks distinct audit ids (claimed-entry skip); both stay answerable', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const agent = agentOf(ctx) + const askA = ctx.approval.request({ agent, toolName: 'alpha' }) + const askB = ctx.approval.request({ agent, toolName: 'beta' }) + await waitForCount(mux, 'approval/requested', 2) + const frames = mux.envelopes.filter(e => e.payload.type === 'approval/requested') + const frameA = frames.find(e => requestedOf(e.payload).toolName === 'alpha') as RpcRequest + const frameB = frames.find(e => requestedOf(e.payload).toolName === 'beta') as RpcRequest + // Without a callId the pairing is heuristic, but never shared: the second + // dispatch skips the id the first pending entry already claimed. + expect(requestedOf(frameA.payload).approvalId).not.toBe(requestedOf(frameB.payload).approvalId) + expect(await api.respond(answer(frameA.rpcId, agent.session.id, requestedOf(frameA.payload).approvalId, 'allowed-once'))) + .toEqual({ accepted: true }) + expect(await api.respond(answer(frameB.rpcId, agent.session.id, requestedOf(frameB.payload).approvalId, 'rejected'))) + .toEqual({ accepted: true }) + await expect(askA).resolves.toBe('allowed-once') + await expect(askB).resolves.toBe('rejected') + abort.abort() + }) + + it('delegates a dispatch whose only asked candidate is already decided (stale re-dispatch)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the service + // Bypass ApprovalService: a log whose sole asked event already has its + // decided partner must not be re-claimed — the answerer delegates. + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' }) + session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'bash' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) + + it('delegates an ask whose session log carries no asked audit event (foreign channel)', async () => { + const { ctx, api } = await harness() + void api // the answerer is registered; the fake below bypasses the audit path + // Bypass ApprovalService: dispatch the waterfall directly with a session + // that has no approval/asked event — the proxy answerer must call next(). + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const agent = { session } as unknown as Agent + const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const)) + expect(outcome).toBe('unavailable') + }) +}) diff --git a/packages/host/runtime/tests/api-proxy-permission.spec.ts b/packages/host/runtime/tests/api-proxy-permission.spec.ts new file mode 100644 index 0000000000..472a49a52b --- /dev/null +++ b/packages/host/runtime/tests/api-proxy-permission.spec.ts @@ -0,0 +1,152 @@ +/** + * Permission select over the proxy: permissions() projects the preset table + * plus the derived current value (custom shown only when derived), + * setPermission() validates against the table and anchors idle switches to + * the next prompted turn (the ACP bridge's pendingSwitches pattern), and a + * permission-less composition serves an empty select instead of an error. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import PermissionService from '@deepseek-ai/dsh-permission' +import type { ApiProxy, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { createApiProxy } from '../src/api-proxy.ts' + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } +} + +async function harness(options: { permission?: boolean } = {}): Promise<{ ctx: Context; api: ApiProxy; sessionId: SessionId }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + if (options.permission !== false) { + // The permission service requires a confining executor fact + approval. + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission proxy tests do not execute bash') }, + run() { throw new Error('permission proxy tests do not execute bash') }, + start() { throw new Error('permission proxy tests do not execute bash') }, + }) + await ctx.plugin(ApprovalService) + await ctx.plugin(PermissionService, {}) + } + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + // No agent-loop in this harness: register a bare live agent directly (the + // proxy only reaches `.session`); api-proxy-view.spec.ts precedent. + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return { ctx, api, sessionId: session.id } +} + +function expectOk(response: { result: { ok: true; value: T } | { ok: false } }): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +describe('session.permissions', () => { + it('projects the preset table with the effective current value; custom is absent when a preset matches', async () => { + const { api, sessionId } = await harness() + const value = expectOk<{ options: { value: string }[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value.currentValue).toBe('workspace-write') + expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access']) + }) + + it('serves an empty select (custom) on a permission-less composition', async () => { + const { api, sessionId } = await harness({ permission: false }) + const value = expectOk<{ options: unknown[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value).toEqual({ options: [], currentValue: 'custom' }) + }) + + it('appends the derived custom option when the knobs match no preset', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('sandbox/mode', { mode: 'read-only' }) + const value = expectOk<{ options: { value: string }[]; currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(value.currentValue).toBe('custom') + expect(value.options.map(o => o.value)).toEqual(['workspace-write', 'danger-full-access', 'custom']) + }) + + it('propagates the agentFor error for a ghost session (persistence-less harness: internal)', async () => { + // The not-found/internal split is agentFor's documented gate and already + // covered by the history specs; here only the pass-through matters. + const { api } = await harness() + const response = await api.sessions.permissions(request({ sessionId: 'session-void' as SessionId })) + expect(response.result.ok).toBe(false) + }) +}) + +describe('session.setPermission', () => { + it('holds an idle switch pending (visible in permissions()) and flushes it into the next prompted turn', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + expect(agent).toBeDefined() + const switched = expectOk<{ currentValue: string }>( + await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) + expect(switched.currentValue).toBe('danger-full-access') + // No turn open: nothing appended yet; the pending value masks the fold. + expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) + const echoed = expectOk<{ currentValue: string }>( + await api.sessions.permissions(request({ sessionId }))) + expect(echoed.currentValue).toBe('danger-full-access') + + // The waterfall flush path: prompt-submit inside the new turn writes through. + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await ctx.waterfall('agent/prompt-submit', agent as never, [], { kind: 'user' } as never, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const })) + expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') + expect(agent?.session.events.map(e => e.type)).toContain('sandbox/mode') + expect(agent?.session.events.map(e => e.type)).toContain('approval/policy') + }) + + it('writes through immediately inside an open turn', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expectOk(await api.sessions.setPermission(request({ sessionId, value: 'danger-full-access' }))) + expect(agent?.session.events.map(e => e.type)).toContain('permission/preset') + }) + + it('acknowledges a current-value echo without recording a switch', async () => { + const { ctx, api, sessionId } = await harness() + const agent = ctx.agents.get(sessionId) + agent?.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + const echoed = expectOk<{ currentValue: string }>( + await api.sessions.setPermission(request({ sessionId, value: 'workspace-write' }))) + expect(echoed.currentValue).toBe('workspace-write') + expect(agent?.session.events.some(e => e.type === 'permission/preset')).toBe(false) + }) + + it('propagates the agentFor error for a ghost session', async () => { + const { api } = await harness() + const response = await api.sessions.setPermission(request({ sessionId: 'session-void' as SessionId, value: 'workspace-write' })) + expect(response.result.ok).toBe(false) + }) + + it('rejects unknown values (custom included) and a permission-less composition as bad-request', async () => { + const { api, sessionId } = await harness() + for (const value of ['custom', 'nope']) { + const response = await api.sessions.setPermission(request({ sessionId, value })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + } + const bare = await harness({ permission: false }) + const response = await bare.api.sessions.setPermission(request({ sessionId: bare.sessionId, value: 'workspace-write' })) + expect(response.result.ok).toBe(false) + if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') + }) +}) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index aee28b5371..da3d934a17 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -48,16 +48,31 @@ "path": "../../session-persistence/session-persistence-jsonl" }, { - "path": "../../bash/bash-local" + "path": "../../bash/bash-sandbox" }, { "path": "../../bash/tool-bash" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-local" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../ui/user-approval" + }, + { + "path": "../../ui/permission" + }, { "path": "../../compact/compact-basic" }, { - "path": "../../fs/fs-local" + "path": "../../fs/fs-sandbox" }, { "path": "../../fs/fs-policy" diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8a731a1bf6..74127261bd 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -60,7 +60,7 @@ import { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-commands' import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference' -import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' +import { displayPromptContent, hasOpenTurn, SessionId, type JsonValue } from '@deepseek-ai/dsh-session' // Side-effect type import: resolves `ctx.get('permission')` to the service. import type {} from '@deepseek-ai/dsh-permission' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' @@ -708,17 +708,6 @@ export function apply(ctx: Context, config: AcpConfig): void { }] } - /** Whether the log has an open turn in which a config switch can be enclosed. */ - const isTurnOpen = (agent: Agent): boolean => { - const events = agent.session.events - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false - } - /** Anchor last-write-wins idle switches into a just-opened turn. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches @@ -1159,7 +1148,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (!presets.names.includes(params.value)) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - if (isTurnOpen(rec.agent)) presets.set(rec.agent.session, params.value) + if (hasOpenTurn(rec.agent.session.events)) presets.set(rec.agent.session, params.value) else rec.pendingSwitches.preset = params.value break } diff --git a/packages/ui/user-approval/src/index.ts b/packages/ui/user-approval/src/index.ts index 329fad33a7..45247b5d3f 100644 --- a/packages/ui/user-approval/src/index.ts +++ b/packages/ui/user-approval/src/index.ts @@ -11,6 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' +import { hasOpenTurn } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -138,22 +139,6 @@ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): Approv return undefined } -/** - * Whether the log currently sits inside an open turn (a `turn/start` not yet - * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. - * The audit pair must be turn-enclosed: the turn is the durable log's - * commit/replay boundary, so a bare event appended between turns is - * indistinguishable from a crash tail and silently dropped on reload. - */ -function hasOpenTurn(events: readonly SessionEvent[]): boolean { - for (let index = events.length - 1; index >= 0; index -= 1) { - const type = (events[index] as SessionEvent).type - if (type === 'turn/start') return true - if (type === 'turn/end') return false - } - return false -} - /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21eb750454..5083d449ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2091,18 +2091,18 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-bash-local': + '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ - version: link:../../bash/bash-local + version: link:../../bash/bash-sandbox '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../compact/compact-basic - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../fs/fs-sandbox '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../apiproxy @@ -2112,6 +2112,18 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2187,6 +2199,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction From d2fea6d7894593d4a64f85bb1a1474f2996f6fec Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 14:40:13 +0800 Subject: [PATCH 002/153] fix(apiproxy): refuse non-JSON media types on /api POST bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers send "simple" POSTs (text/plain, form encodings) without a CORS preflight, so a malicious page could execute side-effectful RPCs blind — the response stays unreadable cross-origin, but session.prompt would still run. The carrier now answers 415 unless the declared media type is application/json, forcing every cross-site attempt into a preflight this server never answers. Raw-fetch specs gain the header; a new handler case proves the fence rejects before the impl runs. --- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/fetch/handler.ts | 15 ++++++++-- .../apiproxy/tests/client-handler.spec.ts | 30 +++++++++++++++---- .../host/apiproxy/tests/fetch-carrier.spec.ts | 24 +++++++-------- 6 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 28ff470c0b..ede79d610b 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: d6db9a9541b0727b61dbe501f7234564ffef139e -README.zh.md: 4175c8fdb98aad2882718a2c95cd9e45825d787d +README.md: 63294100cd0dc62f9822a3ca9678c1034880169f +README.zh.md: 251b4b0356da5f1fb518d133a92f484da957b951 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d6db9a9541..63294100cd 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod ## Contract layer (`/api`) -Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. +Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload`/`ResponseValue`. Zod schemas anchor `satisfies z.ZodType>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind. The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4175c8fdb9..251b4b0356 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -6,7 +6,7 @@ ## 契约层(`/api`) -协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。 +协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`(POST `/api/` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`(SSE 帧)和 `ClientResponse`(POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi`、`HostApi`、`EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload`/`ResponseValue` 派生。Zod schema 以 `satisfies z.ZodType>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合);HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f0535dc939..8160f9d69a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -2,8 +2,8 @@ * Server side of the fetch carrier: maps an ApiProxy onto a pure * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method + * path==method) -> payload dispatched per method. HTTP status expresses only the carrier - * (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always - * 200 + ServerResponse. + * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash); + * business errors are always 200 + ServerResponse. */ import { randomUUID } from 'node:crypto' @@ -188,6 +188,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { return new Response('not found', { status: 404 }) } + // Cross-site write fence: browsers send "simple" POSTs (text/plain, + // form encodings) without a CORS preflight, so a malicious page could + // otherwise execute side-effectful RPCs blind — the response stays + // unreadable cross-origin, but session.prompt would still run. Only the + // JSON media type is accepted; anything else is forced into a preflight + // this server never answers. 415 = carrier layer, like the 400 below. + const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + let body: unknown try { body = await req.json() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2dbc962a00..0885b7b896 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -138,7 +138,7 @@ describe('unary round trip', () => { it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) }) + const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) expect(response.status).toBe(200) const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } expect(parsed.result.ok).toBe(false) @@ -149,13 +149,13 @@ describe('unary round trip', () => { it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) }) + const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) expect(noId.status).toBe(200) const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } expect(noIdParsed.result.ok).toBe(false) expect(noIdParsed.rpcId).toBe('invalid-request') // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) + const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } expect(withIdParsed.result.ok).toBe(false) expect(withIdParsed.rpcId).toBe('salvage-me') @@ -164,16 +164,34 @@ describe('unary round trip', () => { it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => { const handler = toFetchHandler(scriptedApi()) // Unknown method → 404. - const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' }) + const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) expect(notFound.status).toBe(404) // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' }) + const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) expect(badBody.status).toBe(400) // Impl crash → 500, and through the client that is a throw, not an err result. const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } }) await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/) }) + it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { + const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] })) + const handler = toFetchHandler(scriptedApi({ sessions: { list } })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} }) + // A "simple" browser POST (text/plain — sent with no CORS preflight) is + // refused at the carrier before the impl runs. + const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) + expect(plain.status).toBe(415) + // A string body with no explicit header defaults to text/plain — same fence. + const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body }) + expect(unlabelled.status).toBe(415) + expect(list).not.toHaveBeenCalled() + // Media-type parameters pass: the fence checks the type, not the exact string. + const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) + expect(charset.status).toBe(200) + expect(list).toHaveBeenCalledTimes(1) + }) + it('rejects when the transport never resolves within timeoutMs', async () => { // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast. const never = new InProcessApiClient({ @@ -421,7 +439,7 @@ describe('respond path', () => { it('returns bad-response for a malformed client-response without reaching the impl', async () => { const respond = vi.fn() const handler = toFetchHandler(scriptedApi({ respond })) - const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) }) + const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) }) expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' }) expect(respond).not.toHaveBeenCalled() }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 0e6dc8dc4a..318dbc970e 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -223,7 +223,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } }) // The fake's /hang settles only when the invoke-level signal aborts: a // completed response with the cancelled error proves req.signal reached it. - const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal })) + const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal })) controller.abort() const response = await pending const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } @@ -248,7 +248,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const controller = new AbortController() const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} }) const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', { - method: 'POST', body, signal: controller.signal, + method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, })) controller.abort() const parsed = await (await pending).json() as { result: { error?: { code: string } } } @@ -260,18 +260,18 @@ describe('handler carrier-layer statuses', () => { const handler = toFetchHandler(fakeApi()) it('404s unknown paths and non-POST non-stream methods', async () => { - expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) + expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) }) it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) expect(response.status).toBe(400) }) it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) expect(response.status).toBe(200) const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } expect(body.rpcId).toBe('invalid-request') @@ -280,7 +280,7 @@ describe('handler carrier-layer statuses', () => { it('rejects a method/path mismatch echoing the envelope rpcId', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } expect(parsed.rpcId).toBe('r-9') expect(parsed.result.error?.message).toContain('does not match path') @@ -288,7 +288,7 @@ describe('handler carrier-layer statuses', () => { it('rejects an invalid payload with the zod issues attached', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body })) + const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) @@ -297,23 +297,23 @@ describe('handler carrier-layer statuses', () => { it('500s when the impl itself throws', async () => { const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' })) const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body })) + const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) expect(response.status).toBe(500) expect(await response.text()).toContain('impl crashed') }) it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => { const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } }) - const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json() + const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json() expect(goodReceipt).toEqual({ accepted: true }) const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} }) - const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json() + const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json() expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' }) }) it('accepts (url, init) form fetch invocation', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} }) - const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body }) + const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) expect(response.status).toBe(200) }) }) From 01d68dee4e995a402fa6ebd7dcc511266e0c6300 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 14:56:45 +0800 Subject: [PATCH 003/153] fix(connection): fence every /api request behind one browser-trust check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only browser-trust guard covered host.pickDirectory, while the consequential methods (session.prompt drives bash) accepted any Host — open to DNS rebinding, where a rebound page reads and writes the API as if same-origin and only the Host header betrays the attacker's domain. The pickDirectory-specific loopback guard becomes a prefix-wide fence: Host must be loopback or an exact host[:port] from the new trustedHosts config, an attached Origin must equal that authority, and explicit cross-site markers are refused; requests without browser markers (curl, tests, native clients) pass, because without a browser there is no confused deputy. The loopback-socket check is dropped — binding policy expresses reachability, and the fence is not an auth layer. The Agent Note records the full threat model and the alternatives. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 6 + .../2026-07-28-api-browser-trust-boundary.md | 31 +++++ ...026-07-28-api-browser-trust-boundary.zh.md | 31 +++++ docs/config-catalog.md | 20 +++- packages/client/connection/README.i18n.yaml | 6 +- packages/client/connection/README.md | 4 + packages/client/connection/README.zh.md | 4 + packages/client/connection/package.json | 3 +- .../connection/src/api-request-trust.ts | 71 +++++++++++ packages/client/connection/src/index.ts | 31 ++++- .../connection/src/native-dialog-request.ts | 52 -------- .../tests/api-request-trust.spec.ts | 60 ++++++++++ .../tests/native-dialog-request.spec.ts | 57 --------- .../client/connection/tests/node-half.spec.ts | 112 ++++++++++++------ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- pnpm-lock.yaml | 3 + 18 files changed, 339 insertions(+), 160 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md create mode 100644 packages/client/connection/src/api-request-trust.ts delete mode 100644 packages/client/connection/src/native-dialog-request.ts create mode 100644 packages/client/connection/tests/api-request-trust.spec.ts delete mode 100644 packages/client/connection/tests/native-dialog-request.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml new file mode 100644 index 0000000000..11973b755f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md +2026-07-28-api-browser-trust-boundary.md: c620f1a65e3890bbd2580415e55b25436fefe36e +2026-07-28-api-browser-trust-boundary.zh.md: 0452eff1017b2f70a00e67c5cfce8dba3a840539 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md new file mode 100644 index 0000000000..c620f1a65e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -0,0 +1,31 @@ +# Agent Note: One carrier-level browser-trust boundary for the whole /api surface + +Status: implemented + +English | [中文](2026-07-28-api-browser-trust-boundary.zh.md) + +## Problem + +The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--host 0.0.0.0` supported), and the surface includes remote-code-execution-grade methods — `session.prompt` drives an agent that runs bash. A browser turns the operator into a confused deputy against such a local API in two classic ways: a malicious page fires a "simple" cross-site POST (`text/plain` — sent without a CORS preflight) whose side effects execute even though the response stays unreadable, and a DNS-rebound origin talks to the socket as if same-origin, making CORS inapplicable entirely, with only the `Host` header betraying the attacker's domain. Before this decision the system's only browser-trust check (`isTrustedNativeDialogRequest`: loopback socket + same-origin + loopback Host) guarded exactly one cosmetic route — `host.pickDirectory`, whose native dialog pops on the host's screen — while every consequential method was unguarded. Guarding per-RPC also could not survive the upcoming in-app directory browser, whose whole point is serving legitimately remote clients that a loopback rule would refuse. + +## Decision + +Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: + +- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: `Host` must be loopback or an exact `host[:port]` from the plugin's `trustedHosts` config (rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. Requests without browser markers pass — a non-browser client is the principal itself, not a deputy. `host.pickDirectory` loses its bespoke guard and rides the same fence. + +Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. + +## Alternatives considered + +- **Per-RPC guards (status quo extended).** Rejected: the guard list trails the method list forever, the highest-value methods were already unguarded, and a loopback rule on browse RPCs would break the remote deployments they exist for. +- **CORS headers + credential omission.** Rejected: we never want cross-origin reads at all, so answering preflights only widens the surface; refusing them is strictly stronger and simpler. +- **Auth tokens now.** Rejected for this change: token minting/storage/rotation is real product surface; the fence closes the browser-deputy holes today without pre-deciding the auth design. + +## Consequences + +- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. +- Non-loopback deployments must declare their serving authorities in `trustedHosts` or browsers are refused; plain curl-shape automation is unaffected either way. +- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). +- The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md new file mode 100644 index 0000000000..0452eff101 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -0,0 +1,31 @@ +# Agent Note:整个 /api 面共用一道载体级浏览器信任边界 + +状态:已实现 + +[English](2026-07-28-api-browser-trust-boundary.md) | 中文 + +## 问题 + +Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--host 0.0.0.0`),而这个面上有远程代码执行级别的方法——`session.prompt` 驱动的 agent 可以运行 bash。浏览器会用两种经典方式把操作者变成攻击此类本地 API 的"混淆代理人":恶意页面发出跨站"简单请求" POST(`text/plain`——不经 CORS 预检即发出),其副作用照常执行、只是响应不可读;以及 DNS rebinding 后的源以"同源"身份直连 socket,CORS 整体失效,只有 `Host` 头会暴露攻击者的域名。在本决策之前,系统里唯一的浏览器信任检查(`isTrustedNativeDialogRequest`:回环 socket + 同源 + 回环 Host)只守着一个装饰性的路由——`host.pickDirectory`,其原生对话框弹在宿主屏幕上——而所有真正要命的方法都在裸奔。按 RPC 逐个设防也活不过即将到来的应用内目录浏览器:它存在的意义就是服务合法的远程客户端,回环规则恰恰会拒绝它们。 + +## 决策 + +在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: + +- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:`Host` 必须是回环地址,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不带浏览器标头的请求放行——非浏览器客户端是委托人本人,不是代理人。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 + +两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 + +## 曾考虑的替代方案 + +- **按 RPC 设防(延续现状)。** 否决:守卫清单永远追着方法清单跑,价值最高的方法本来就没被守住,而 browse RPC 上的回环规则会破坏它们为之存在的远程部署。 +- **CORS 头 + 省略凭据。** 否决:我们根本不想要任何跨源读取,应答预检只会扩大暴露面;拒绝预检严格更强也更简单。 +- **现在就上认证令牌。** 在本变更中否决:令牌的签发/存储/轮换是真实的产品面;栅栏今天就能封死浏览器代理人漏洞,无需预先决定认证设计。 + +## 后果 + +- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 +- 非回环部署必须在 `trustedHosts` 中声明服务权威,否则浏览器会被拒绝;curl 形态的自动化不受影响。 +- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 +- 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b89a49529c..d30aff1f02 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -270,6 +270,25 @@ Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts) +## `@deepseek-ai/dsh-client-connection` + +Requires: `httpServer` · `apiProxy` + +```ts config-catalog +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Exact `host[:port]` authorities this deployment serves beyond loopback. + * The /api trust fence refuses any request whose Host is neither loopback + * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare + * the names it is reached by. + */ + trustedHosts?: string[] +} +``` + +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) + ## `@deepseek-ai/dsh-client-hmr` Requires: `clientModuleHost` · `httpServer` @@ -2143,7 +2162,6 @@ Source: [`packages/context/workspace-context/src/config.ts:17`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-client-connection` — requires `httpServer` · `apiProxy` ([`packages/client/connection/src/index.ts`](../packages/client/connection/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 0dd8860d65..5c5a825a27 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.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 -README.md: 80228a180faba0c556ff720e999b29b5bb1635b6 -README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819 +# pnpm run verify-translation-pairing --write packages/client/connection/README.md +README.md: a301b85d707d17d1e8159655b540556eee5c9d83 +README.zh.md: 88d7aa806167033308a9913053f621ecea07c3d8 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 80228a180f..a301b85d70 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +## /api browser-trust fence + +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`): the `Host` header must be a loopback authority or an exact `host[:port]` entry from the plugin's `trustedHosts` config (DNS-rebinding defense), an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Requests without browser markers (curl, tests, native clients) pass — without a browser there is no confused deputy. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment must therefore list the authorities it is reached by in `trustedHosts`; the fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). + ## Keyless fixture Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f4b857886b..88d7aa8061 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -4,6 +4,10 @@ 协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +## /api 浏览器信任栅栏 + +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`):`Host` 头必须是回环地址权威,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不带浏览器标头的请求(curl、测试、原生客户端)直接放行——没有浏览器就不存在"混淆代理人"。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署必须在 `trustedHosts` 中列出自己被访问时使用的权威;这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 + ## 无密钥 fixture 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index c26cafb143..76aee0f06e 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -32,7 +32,8 @@ "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "schemastery": "^3.18.0" }, "files": [ "lib/index.js", diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts new file mode 100644 index 0000000000..37819b9fb9 --- /dev/null +++ b/packages/client/connection/src/api-request-trust.ts @@ -0,0 +1,71 @@ +/** + * Browser-trust fence for every /api request. Defends the two confused-deputy + * paths a browser opens against a local HTTP API — DNS rebinding (Host names + * the attacker's domain while the socket reaches this server) and cross-site + * requests fired from a malicious page — without blocking non-browser clients + * (no browser markers → no deputy to confuse) or legitimately remote browsers + * (their authority is declared via `trustedHosts`). Network reachability and + * authentication stay out of scope: binding policy belongs to the webserver + * config, and this fence is not an auth layer. + */ + +import type { IncomingHttpHeaders } from 'node:http' + +/** The request facts the fence reads (structural subset of IncomingMessage). */ +interface ApiTrustRequest { + headers: IncomingHttpHeaders +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** Hostname of a Host-header authority (port stripped, lowercased, IPv6 bracketed), or undefined when unparsable. */ +function authorityHostname(authority: string): string | undefined { + try { + // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. + return new URL(`http://${authority}`).hostname + } catch { + return undefined + } +} + +/** + * Decide whether one /api request may reach the RPC bridge. + * @param request - node HTTP request facts (headers). + * @param trustedHosts - exact non-loopback `host[:port]` authorities this deployment serves. + * @returns true when the Host is ours and any browser markers are same-origin. + */ +export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { + // Host fence (DNS-rebinding defense): the browser fills Host from the URL it + // believes it is talking to, so a rebound page carries the attacker's domain + // here even though the socket lands on this server. + const host = header(request.headers, 'host') + if (host === undefined) return false + const hostname = authorityHostname(host) + if (hostname === undefined) return false + if (!isLoopbackHostname(hostname) && !trustedHosts.includes(host)) return false + // Cross-site fence: modern browsers label the initiator relationship on + // every fetch; an explicit cross-site marker is refused regardless of Origin. + if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false + // Origin fence: when a browser attaches an Origin it must be exactly this + // authority. Absent Origin = non-browser client (curl, tests, native shells) + // — allowed, because without a browser there is no confused deputy. The + // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, 'origin') + if (origin === undefined) return true + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index bc73e0e054..77f463149e 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,11 +1,12 @@ /** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' +import z from 'schemastery' // Activates the httpServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' +import { isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -15,19 +16,37 @@ export const name = 'client-connection' /** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] +/** Plugin config: the deployment's non-loopback serving authorities. */ +export interface ConnectionConfig { + /** + * Exact `host[:port]` authorities this deployment serves beyond loopback. + * The /api trust fence refuses any request whose Host is neither loopback + * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare + * the names it is reached by. + */ + trustedHosts?: string[] +} + +export const Config: z = z.object({ + trustedHosts: z.array(String).default([]), +}) + /** - * Mounts the API gateway under the browser transport prefix. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)). * @param ctx - Host plugin context. + * @param config - resolved plugin config (schema defaults applied). */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config?: ConnectionConfig): void { + // The Loader resolves schema defaults; hand-built test contexts may pass none. + const trustedHosts = config?.trustedHosts ?? [] const apiHandler = toFetchHandler(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if (pathname === `${API_PATH}/host.pickDirectory` - && !isTrustedNativeDialogRequest(req)) { + if (!isTrustedApiRequest(req, trustedHosts)) { res.writeHead(403) res.end('forbidden') return diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts deleted file mode 100644 index fe91bbae2d..0000000000 --- a/packages/client/connection/src/native-dialog-request.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** Trust check for browser requests that can open an operating-system dialog. */ - -import type { IncomingHttpHeaders } from 'node:http' - -interface NativeDialogRequest { - headers: IncomingHttpHeaders - socket: { remoteAddress?: string | undefined } -} - -function header(headers: IncomingHttpHeaders, name: string): string | undefined { - const value = headers[name] - return typeof value === 'string' ? value : undefined -} - -function isLoopback(address: string | undefined): boolean { - if (address === undefined) return false - if (address === '::1') return true - const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address - const first = ipv4.split('.')[0] - return first === '127' -} - -function isLoopbackHostname(hostname: string): boolean { - if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true - const parts = hostname.split('.') - return parts.length === 4 - && parts[0] === '127' - && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) -} - -/** - * Require a local socket plus browser-controlled same-origin metadata. - * @param request - the node HTTP request facts used by the carrier guard. - * @returns true only for a same-origin browser request whose peer and URL are loopback. - */ -export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { - if (!isLoopback(request.socket.remoteAddress)) return false - if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false - const origin = header(request.headers, 'origin') - const host = header(request.headers, 'host') - if (origin === undefined || host === undefined) return false - try { - const parsed = new URL(origin) - const hostUrl = new URL(`http://${host}`) - return (parsed.protocol === 'http:' || parsed.protocol === 'https:') - && parsed.host === host - && isLoopbackHostname(parsed.hostname) - && isLoopbackHostname(hostUrl.hostname) - } catch { - return false - } -} diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts new file mode 100644 index 0000000000..eab878e734 --- /dev/null +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -0,0 +1,60 @@ +/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */ + +import { describe, expect, it } from 'vitest' +import { isTrustedApiRequest } from '../src/api-request-trust.ts' + +function request(headers: Record): { headers: Record } { + return { headers } +} + +describe('isTrustedApiRequest', () => { + it('accepts loopback Hosts in every spelling, with and without ports', () => { + for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { + expect(isTrustedApiRequest(request({ host }), [])).toBe(true) + } + }) + + it('accepts non-browser requests (no Origin, no sec-fetch-site) — curl, tests, native clients', () => { + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + }) + + it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { + expect(isTrustedApiRequest(request({ + host: 'evil.example:3080', + origin: 'http://evil.example:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(false) + }) + + it('accepts a declared public authority only on exact host[:port] match', () => { + const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } + expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request(headers), [])).toBe(false) + }) + + it('refuses cross-origin browser markers even on a loopback Host', () => { + // Origin present and different → cross-site request that survived preflight rules. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false) + // Explicit cross-site label → refused regardless of Origin. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false) + // Opaque origin (sandboxed iframe, file: page) parses to no authority. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false) + }) + + it('accepts a same-origin browser request', () => { + expect(isTrustedApiRequest(request({ + host: 'localhost:3080', + origin: 'http://localhost:3080', + 'sec-fetch-site': 'same-origin', + }), [])).toBe(true) + }) + + it('refuses malformed authorities', () => { + expect(isTrustedApiRequest(request({}), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: '' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'bad host' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: '127.0.0.999' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: '128.0.0.1' }), [])).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts deleted file mode 100644 index 1a3d70dd15..0000000000 --- a/packages/client/connection/tests/native-dialog-request.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { IncomingHttpHeaders } from 'node:http' -import { describe, expect, it } from 'vitest' -import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' - -function request( - remoteAddress: string | undefined, - headers: IncomingHttpHeaders = { - host: '127.0.0.1:3080', - origin: 'http://127.0.0.1:3080', - 'sec-fetch-site': 'same-origin', - }, -) { - return { socket: { remoteAddress }, headers } -} - -describe('native dialog request trust', () => { - it('accepts loopback same-origin browser requests', () => { - expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::1', { - host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - expect(isTrustedNativeDialogRequest(request('127.0.0.2', { - host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(true) - }) - - it('rejects remote sockets and requests without matching browser metadata', () => { - expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) - expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - expect(isTrustedNativeDialogRequest(request('127.0.0.1', { - host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', - }))).toBe(false) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 86af61ba0d..a492a7a9c0 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,4 +1,6 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -6,46 +8,84 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, inject } from '../src/index.ts' +/** Structural httpServer fake: the plugin only touches register(). */ +function fakeHttpServer(routes: WebRoute[]): Pick { + return { + register(route) { + routes.push(route) + return () => { routes.splice(routes.indexOf(route), 1) } + }, + tapIndex: () => () => {}, + port: 0, + } +} + +/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */ +function fakeRequest(headers: Record): IncomingMessage { + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers }) + return request +} + +/** Response recorder compatible with both the fence's short-circuit and the bridge. */ +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(value: number) { state.status = value; return this }, + write() { return true }, + end(this: { writableEnded: boolean }, value?: unknown) { + if (value !== undefined) state.body = value + this.writableEnded = true + return this + }, + }) as unknown as ServerResponse + return { response, state } +} + +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + const fiber = ctx.plugin({ inject: [...inject], apply }, config) + await fiber.await() + return { routes, dispose: () => fiber.dispose() } +} + describe('connection node half', () => { it('registers the /api prefix route and removes it with the fiber', async () => { - const ctx = new Context() - const routes: WebRoute[] = [] - // Structural fake: the plugin only touches register(); the service class - // carries private state a literal cannot (and need not) reproduce. - const httpServer: Pick = { - register(route) { - routes.push(route) - return () => { routes.splice(routes.indexOf(route), 1) } - }, - tapIndex: () => () => {}, - port: 0, - } - ctx.provide('httpServer', httpServer as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) - - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + const { routes, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - - let status: number | undefined - let body: unknown - const deniedRequest = { - url: '/api/host.pickDirectory', - headers: { - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, - socket: { remoteAddress: '192.168.1.8' }, - } as unknown as IncomingMessage - const deniedResponse = { - writeHead(value: number) { status = value; return this }, - end(value?: unknown) { body = value; return this }, - } as unknown as ServerResponse - await routes[0]!.handler(deniedRequest, deniedResponse) - expect(status).toBe(403) - expect(body).toBe('forbidden') - - await fiber.dispose() + await dispose() expect(routes).toHaveLength(0) }) + + it('refuses an untrusted Host on any /api path before the bridge runs', async () => { + const { routes, dispose } = await mounted() + const { response, state } = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }), response) + expect(state.status).toBe(403) + expect(state.body).toBe('forbidden') + await dispose() + }) + + it('passes loopback and declared-authority requests through to the bridge', async () => { + const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080'] }) + // Loopback, no browser markers (curl shape): the fence passes; the carrier + // answers 404 for a GET unary path — proof the bridge ran. + const loopback = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) + expect(loopback.state.status).toBe(404) + // Declared public authority, same-origin browser shape. + const declared = fakeResponse() + await routes[0]!.handler(fakeRequest({ + host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', + }), declared.response) + expect(declared.state.status).toBe(404) + await dispose() + }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index ede79d610b..bfda016b16 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: 63294100cd0dc62f9822a3ca9678c1034880169f -README.zh.md: 251b4b0356da5f1fb518d133a92f484da957b951 +README.md: 2f51aa23e2639e2e98dfdd8aaf71e807d641c3dc +README.zh.md: 687e60879702a762d9e85295789b77daea4bd4ac diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 63294100cd..2f51aa23e2 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,7 +16,7 @@ Session model routing is a session-domain contract. `session.models` returns the Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests. +`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers this method like every other `/api` request. `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 251b4b0356..687e608797 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求。 +`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖该方法。 `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9538051cd..f8343b2404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -797,6 +797,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ From 5081697aafe3b54aa26c2f11dcce1324b8602ad0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 28 Jul 2026 14:58:06 +0800 Subject: [PATCH 004/153] feat(web): render bash tool output as a terminal card The bash tool already declares the `card: 'terminal'` render intent for both its call and its result, and host/connection/runtime already deliver it to the browser as callView/resultView. The Web client ignored it: rows derived from raw args, and the details panel flattened every tool's content into one soft-wrapping `

`. Column-aligned output folded into
a paragraph and a long listing stretched the panel without bound.

`TerminalBlock` (ui-primitives) renders a command as a terminal surface:
a shortened-cwd prompt line, output at `white-space: pre` in a
horizontally scrolling box, a head/tail height cap with an expand
control, an exit-code/signal status pill, and a copy control for the raw
output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*`
theme tokens, with literal rgb kept for values the design system has no
token for. Geometry and fonts mirror CodeBlock; the clipboard write both
need moved into a package-internal `clipboard.ts`.

Both Web render sites for a bash call consume the intent through one
derivation (`terminal-card-model.ts`), so they cannot disagree about a
command, its cwd, or its exit status: the keyed BashRow carries the card
resident below its summary row, and the render-site fallback row keeps it
behind its existing expand control. Rows cap at 8 lines against the
panel's 16.

Inline output in the chat row reverses this package's stated
no-inline-output convention, on the owner's explicit decision; the Agent
Note records the reversal and its bound.

Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring
specs at every render site, a built-client-graph snapshot covering both
chat-row shapes, and a real-browser e2e asserting the no-wrap layout and
the page's own Clipboard API.
---
 .../2026-07-28-web-terminal-card.i18n.yaml    |   6 +
 .../feature/2026-07-28-web-terminal-card.md   |  65 ++++
 .../2026-07-28-web-terminal-card.zh.md        |  65 ++++
 ...26-web-syntax-highlighting-shiki.i18n.yaml |   6 +-
 ...026-07-26-web-syntax-highlighting-shiki.md |   2 +-
 ...-07-26-web-syntax-highlighting-shiki.zh.md |   2 +-
 apps/web/tests/code-mode-fixture.snapshot.ts  |   6 +-
 apps/web/tests/navigation-panes.e2e.ts        |  46 ++-
 .../navigation-panes/details-open.expected.md |   7 +-
 .../terminal-card.expected.md                 |   3 +
 apps/web/tests/terminal-card.snapshot.ts      | 324 ++++++++++++++++
 .../client/connection/src/client/fixture.ts   |  73 +++-
 .../client/ui-conversation/README.i18n.yaml   |   4 +-
 packages/client/ui-conversation/README.md     |   2 +
 packages/client/ui-conversation/README.zh.md  |   2 +
 .../src/client/chat/GenericToolCard.tsx       |   2 +
 .../src/client/chat/ToolRow.module.css        |  12 +-
 .../src/client/chat/ToolRow.tsx               |  39 +-
 .../client/contract/terminal-card-model.ts    |  83 ++++
 .../src/client/contract/tool-call-model.ts    |   6 +-
 .../client/skeleton/DetailsPanel.module.css   |   6 +
 .../src/client/skeleton/DetailsPanel.tsx      |  79 ++--
 .../client/toolviews/bash-sample.module.css   |  16 +-
 .../src/client/toolviews/bash-sample.tsx      |  51 ++-
 .../tests/chat-tool-row.spec.tsx              |  21 +
 .../tests/terminal-card.spec.tsx              | 359 ++++++++++++++++++
 .../client/ui-primitives/README.i18n.yaml     |   6 +-
 packages/client/ui-primitives/README.md       |   7 +-
 packages/client/ui-primitives/README.zh.md    |   7 +-
 packages/client/ui-primitives/package.json    |   1 +
 packages/client/ui-primitives/src/Pill.tsx    |   4 +-
 .../src/TerminalBlock.module.css              | 101 +++++
 .../ui-primitives/src/TerminalBlock.tsx       | 170 +++++++++
 packages/client/ui-primitives/src/ansi.ts     | 153 ++++++++
 .../client/ui-primitives/src/clipboard.ts     |  48 +++
 packages/client/ui-primitives/src/index.ts    |   2 +
 .../ui-primitives/src/markdown/CodeBlock.tsx  |  40 +-
 .../client/ui-primitives/tests/ansi.spec.ts   | 188 +++++++++
 .../tests/terminal-block.spec.tsx             | 315 +++++++++++++++
 pnpm-lock.yaml                                |   8 +
 40 files changed, 2218 insertions(+), 119 deletions(-)
 create mode 100644 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
 create mode 100644 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
 create mode 100644 .agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
 create mode 100644 apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
 create mode 100644 apps/web/tests/terminal-card.snapshot.ts
 create mode 100644 packages/client/ui-conversation/src/client/contract/terminal-card-model.ts
 create mode 100644 packages/client/ui-conversation/tests/terminal-card.spec.tsx
 create mode 100644 packages/client/ui-primitives/src/TerminalBlock.module.css
 create mode 100644 packages/client/ui-primitives/src/TerminalBlock.tsx
 create mode 100644 packages/client/ui-primitives/src/ansi.ts
 create mode 100644 packages/client/ui-primitives/src/clipboard.ts
 create mode 100644 packages/client/ui-primitives/tests/ansi.spec.ts
 create mode 100644 packages/client/ui-primitives/tests/terminal-block.spec.tsx

diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
new file mode 100644
index 0000000000..347d5978c0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-web-terminal-card.md
+2026-07-28-web-terminal-card.md: 76d5c47054378330da9e9eebb70571925e47f741
+2026-07-28-web-terminal-card.zh.md: 83d5e7f72d9b04358ce4fe1fd9295e5c97045031
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
new file mode 100644
index 0000000000..76d5c47054
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
@@ -0,0 +1,65 @@
+# Agent Note: Web terminal card — the bash render intent reaches the browser
+
+Status: implemented
+
+English | [中文](2026-07-28-web-terminal-card.zh.md)
+
+## Problem
+
+The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as a `$`-prompt card with an exit line and a head/tail height cap.
+
+The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
+
+## Decision
+
+`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering.
+
+The component's contract:
+
+- **Prompt line.** A shortened cwd label followed by the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`.
+- **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
+- **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
+- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output.
+- **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.
+
+Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks.
+
+### Inline output in the chat row reverses a stated convention
+
+`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
+
+The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.
+
+The remaining bound: the row caps at `CHAT_TERMINAL_MAX_LINES` (8), half the primitive's default, which the panel keeps — the message flow is a summary surface read across many calls, the panel is the single-call reading surface, so the panel stays the place for the full output. Only the terminal intent renders inline; a generic tool's content is still panel-only.
+
+## Alternatives considered
+
+**Render the terminal block only in the details panel.** This keeps the stated no-inline-output convention and needs no reversal to record. Rejected by the owner's explicit decision: a shell command's output is what the user came to read, and putting it one click away costs more than the convention buys. Recorded here as the owner's call, not as a conclusion derived from the codebase.
+
+**Reuse `CodeBlock` with a `console` language instead of a new primitive.** Rejected: `CodeBlock` soft-wraps, which is the defect being fixed, and it has no exit status, no cwd prompt line, no height cap, and no ANSI handling. Adding four terminal-specific concerns to the shared code-fence component would impose them on every markdown fence. The two components share their geometry and font tokens instead, which is the only part where one implementation is correct for both.
+
+**Hand-roll the SGR parser.** Rejected: an SGR parser is exactly the surface [prefer maintained dependencies over hand-rolling](../process/2026-07-26-dependencies-over-hand-rolling.md) says not to own — its edge cases (256-palette and truecolor forms, `reverse`, multi-parameter runs, unterminated sequences) each fail on output nobody produces in a test, so a hand-rolled version stays subtly wrong for a long time. Stated honestly against that policy's bar: `anser` does **not** delete existing owned code. It is a capability addition, which that note distinguishes from a net-deletion simplification; the health and boundary-fit halves of the bar are what it clears. What stays hand-rolled is the part `anser` does not cover: the theme-token color mapping, the non-CSI sanitizing, the carriage-return redraw, and the per-line span folding the height cap slices.
+
+## Consequences
+
+`anser` is a new runtime dependency of `packages/client/ui-primitives`, so every consumer of that package pays for it once. A bash row in the Web chat carries output, which is a deliberate density increase over a summary-only row; the cap is what keeps it bounded, and a tighter cap is a props change, not a redesign.
+
+`TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed.
+
+Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own.
+
+## Testing
+
+`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly.
+
+`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files.
+
+`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker.
+
+`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one.
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a full consumer of the `terminal` arm rather than of args alone.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and its shiki arm, and records why tool output deliberately stays unhighlighted; ANSI color here is authored color, not guessed grammar.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
new file mode 100644
index 0000000000..83d5e7f72d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -0,0 +1,65 @@
+# Agent Note: Web terminal card — the bash render intent reaches the browser
+
+Status: implemented
+
+[English](2026-07-28-web-terminal-card.md) | 中文
+
+## Problem
+
+bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——TUI 也早已把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
+
+Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
+
+## Decision
+
+`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。
+
+该组件的契约:
+
+- **提示符行。** 一个缩短的 cwd 标签,其后原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。
+- **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
+- **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
+- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。
+- **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
+
+几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
+
+### 聊天行内嵌输出推翻了一条既有约定
+
+`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
+
+这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
+
+余下的约束:行内上限为 `CHAT_TERMINAL_MAX_LINES`(8),是组件默认值的一半,而面板沿用默认值——消息流是跨多次调用阅读的摘要表面,面板才是单次调用的阅读表面,因此面板仍是查看完整输出的地方。只有 terminal 意图内嵌渲染;generic 工具的内容依旧只在面板中。
+
+## Alternatives considered
+
+**只在详情面板渲染终端块。** 这样保留既有的「不内嵌输出」约定,也不需要记录任何推翻。已被 owner 的明确决定否决:shell 命令的输出正是用户来读的东西,把它挪到一次点击之外,代价高于该约定带来的收益。此处记录的是 owner 的裁决,而非从代码库推导出的结论。
+
+**复用 `CodeBlock` 并传入 `console` 语言,而不新建组件。** 已否决:`CodeBlock` 会软换行,而软换行正是本次要修的缺陷,且它没有退出状态、没有 cwd 提示符行、没有高度上限、也不处理 ANSI。把四项终端专属关注点加进共享的代码围栏组件,等于把它们强加给每一个 markdown 围栏。两个组件改为共享几何与字体 token,那是唯一一处「一套实现对两者都正确」的部分。
+
+**手写 SGR 解析器。** 已否决:SGR 解析器恰是[优先采用维护良好的依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.md)所指明不该自持的那类实现——它的边界情形(256 色板与 truecolor 形式、`reverse`、多参数分段、未终止的序列)各自只在没人会写进测试的输出上失效,因此手写版本会在很长时间内一直微妙地出错。对照那条策略的门槛如实陈述:`anser` **并未**删除任何既有自持代码。它是一次能力增补,而那条 Agent Note 把这与净删除式的简化区分开来;它清过的是健康度与边界契合这两半门槛。`anser` 未覆盖而仍由我们手写的部分是:主题 token 的颜色映射、非 CSI 序列的剥除、回车重绘,以及供高度上限切片的逐行 span 折叠。
+
+## Consequences
+
+`anser` 成为 `packages/client/ui-primitives` 的一项新运行时依赖,因此该包的每个消费方都为它支付一次。Web 聊天中的 bash 行现在承载输出,相比只有摘要的行,这是有意提高的信息密度;上限是维持其有界的机制,而调紧上限是改一个 prop,不是重新设计。
+
+`TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。
+
+内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。
+
+## Testing
+
+`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。
+
+`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
+
+`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。
+
+`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。
+
+## Related
+
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md)——本次消费的 `card` 标签词汇;Web client 现在是 `terminal` 分支的完整消费方,而不再只消费参数。
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md)——它拥有 `CodeBlock` 及其 shiki 分支,并记录了工具输出为何有意不做语法高亮;这里的 ANSI 颜色是作者指定的颜色,不是猜出来的语法。
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md)——两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
index 9fd37bcedb..72cbbc368f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
index b329e35f1d..48a1e4c43f 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
@@ -17,7 +17,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
 - **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
 - **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
 - **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
-- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
+- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through [the terminal card](../feature/2026-07-28-web-terminal-card.md).
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
index 8e9d1f0d0c..780b66a309 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
@@ -17,7 +17,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
 - **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
-- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
 
 ## 曾考虑的替代方案
 
diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts
index f53777fff8..101859cc4a 100644
--- a/apps/web/tests/code-mode-fixture.snapshot.ts
+++ b/apps/web/tests/code-mode-fixture.snapshot.ts
@@ -213,9 +213,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
   }).toMatchInlineSnapshot(`
     {
       "subCells": [
-        "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
-        "#52Subread · {"path":"notes/demo.txt"}+0.8s",
-        "#53Subread · {"path":"notes/missing.txt"}+0.8s",
+        "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
+        "#50Subread · {"path":"notes/demo.txt"}+0.8s",
+        "#51Subread · {"path":"notes/missing.txt"}+0.8s",
       ],
     }
   `)
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index bbae7363df..b0e340da43 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -26,6 +26,7 @@ const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
 const DETAILS_EXPECTED = join(SNAPSHOT_DIR, 'details-open.expected.md')
+const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
 
@@ -170,9 +171,11 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await bashRow.click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).toBeNull()
     // The open panel shows the selected call's name, arguments, and durable
-    // result (NAVIGATION_OK appears in the chat row too, hence >= 2 total).
+    // result. The chat row carries its own terminal card, so NAVIGATION_OK
+    // appears there too — in the prompt line and in the output.
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
-    // Golden of the open panel: tool name header, Input args, Output result.
+    // Golden of the open panel: tool name header, Input args, and the Output
+    // section's terminal card (prompt line + captured output).
     const snapshot = (await captureStableAria(page, '[class*="detailsCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(DETAILS_EXPECTED, snapshot, MODE)
@@ -180,11 +183,50 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 10_000 }).not.toBeNull()
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
+    await page.getByRole('tab', { name: 'Chat' }).click()
+    // The card is resident in the keyed bash row (no expand gesture): the
+    // recorded command's own output sits in the message flow, derived from the
+    // logged call/result presentations alone.
+    const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+    await card.waitFor({ timeout: 15_000 })
+    // Real layout, not jsdom's stub (which computes no geometry at all):
+    // squeeze the output pane below its content width and the line must keep
+    // its single row and overflow sideways instead of folding. Soft-wrapping
+    // here is what shredded the column alignment this card exists to hold.
+    const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
+      const pane = node as HTMLElement
+      const row = pane.querySelector('[class*="_line_"]')
+      if (row === null) throw new Error('output pane has no line')
+      const before = row.offsetHeight
+      const restore = pane.style.width
+      pane.style.width = '8px'
+      const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
+      pane.style.width = restore
+      return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
+    })
+    expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
+    // Golden of the card at rest — captured before the copy click, whose
+    // confirmation label self-reverts on a timer and would not hold still.
+    const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
+    // Copy writes the raw output through the browser's own clipboard, which in
+    // a real page is the async Clipboard API rather than the jsdom fallback.
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await card.locator('[class*="_copyButton_"]').first().click()
+    await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
+      .toBe('复制成功')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'details-open.expected.md',
+      'terminal-card.expected.md',
     ])
   })
 })
diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
index d69a95eb2d..9eee71468d 100644
--- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
+++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md
@@ -1,5 +1,8 @@
 - text: bash
 - button "关闭详情"
-- text: Input
+- text: Input json
+- button "复制"
 - code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
-- text: Output NAVIGATION_OK
+- text: Output $ echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
new file mode 100644
index 0000000000..2b81725468
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
@@ -0,0 +1,3 @@
+- text: $ echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK
diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts
new file mode 100644
index 0000000000..53215f4ba9
--- /dev/null
+++ b/apps/web/tests/terminal-card.snapshot.ts
@@ -0,0 +1,324 @@
+// @vitest-environment jsdom
+// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session and pins the `card: 'terminal'` render
+// intent at both of its conversation render sites, for both chat-row shapes:
+// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
+// turn 66's `bash` on the keyed BashRow registration (resident body), plus the
+// details panel's Output section. Turn 66 carries what turn 60's three plain
+// lines cannot — SGR runs resolved to --dsw-* tokens, output past the chat
+// cap, a nested cwd, and a non-zero exit pill.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  {
+    id: '@deepseek-ai/dsh-client-ui-workspace',
+    dir: 'ui-workspace',
+    url: '/plugins/ui-workspace.js',
+    rev: 'fx',
+    inject: [
+      '@deepseek-ai/dsh-client-runtime',
+      '@deepseek-ai/dsh-client-ui-conversation',
+      '@deepseek-ai/dsh-client-ui-sidebar',
+    ],
+  },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+  localStorage.clear()
+  document.title = 'DeepSeek Harness'
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+    setTimeout(() => { callback(0) }, 0) as unknown as number)
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+  act(() => { unmount?.() })
+  unmount = undefined
+  cleanup()
+  delete win.__DSH_BOOT__
+  delete win.__ModuleLoader__
+  document.body.innerHTML = ''
+  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+  document.title = ''
+  history.replaceState(null, '', '/')
+  vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against the populated fixture branch. */
+function boot(): void {
+  history.replaceState(null, '', '/?fixture')
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      fetchBundle: (url) => {
+        const code = bundles.get(url)
+        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+      },
+      executeBundle: (code) => { (0, eval)(code) },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * Read one terminal card's user-visible state. Output lines keep their interior
+ * whitespace: holding column alignment is what this card exists for, so
+ * collapsing runs of spaces would hide the behavior under test.
+ */
+function readCard(card: Element) {
+  const status = card.querySelector('[class*="_status_"]')
+  const expander = card.querySelector('button[aria-expanded]')
+  return {
+    prompt: `${card.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${card.querySelector('[class*="_command_"]')?.textContent ?? ''}`,
+    status: status === null ? null : status.textContent,
+    copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
+    lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
+    expander: expander === null ? null : {
+      label: expander.getAttribute('aria-label'),
+      text: expander.textContent,
+      expanded: expander.getAttribute('aria-expanded'),
+    },
+    // Every color the ANSI parser emits resolves through a --dsw-* token, so
+    // the card follows the theme instead of painting literal terminal rgb.
+    colors: [...new Set([...card.querySelectorAll('span[style]')]
+      .map(span => span.getAttribute('style')))],
+  }
+}
+
+/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
+async function openFixtureSession(): Promise {
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  // Anchor on the expandable Workspace group row: the title and the blank
+  // session row can both read "fixture".
+  const group = (await within(tree).findAllByText('fixture'))
+    .map(el => el.closest('[role="treeitem"]'))
+    .find(el => el?.getAttribute('aria-expanded') !== null)
+  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+  if (group.getAttribute('aria-expanded') === 'false') {
+    fireEvent.click(within(group).getByText('fixture'))
+    await waitFor(() => {
+      expect(group.getAttribute('aria-expanded')).toBe('true')
+    })
+  }
+  fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+  await waitFor(() => {
+    expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+}
+
+/** The keyed BashRow of fixture turn 66 (the one carrying the ANSI sample). */
+function keyedBashRow(): Element {
+  const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
+    .find(node => visibleText(node).includes('pnpm run check'))
+  if (row === undefined) throw new Error('keyed bash row for turn 66 missing')
+  return row
+}
+
+/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
+function fallbackBashRow(): Element {
+  const row = document.querySelector('[data-tool="fx-bash"]')
+  if (row === null) throw new Error('fx-bash fallback row missing')
+  return row
+}
+
+it('renders the keyed bash row with a resident terminal card', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = keyedBashRow()
+  const card = row.parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
+  // The prompt shortens the nested cwd to its last segment, the exit pill
+  // recovers the trailing marker's code, ANSI runs land on theme tokens, and
+  // the chat cap (8) collapses the middle into a head/tail split with an
+  // expander between them.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [
+        "font-weight: 700;",
+        "color: var(--dsw-alias-state-success-primary);",
+        "color: var(--dsw-alias-state-error-primary);",
+      ],
+      "copy": "复制",
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 14 行输出",
+        "text": "… 其余 14 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+        "[exit code: 1]",
+      ],
+      "prompt": "nested pnpm run check",
+      "status": "退出码 1",
+    }
+  `)
+})
+
+it('the fallback row reaches the same card through its expand control', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = fallbackBashRow()
+  expect(row.querySelector('[data-terminal]')).toBeNull()
+  const toggle = row.querySelector('button[aria-expanded]')
+  if (toggle === null) throw new Error('fallback row expand control missing')
+  fireEvent.click(toggle)
+  const card = await waitFor(() => {
+    const found = row.querySelector('[data-terminal]')
+    if (found === null) throw new Error('terminal card missing after expanding the fallback row')
+    return found
+  })
+  // Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [],
+      "copy": "复制",
+      "expander": null,
+      "lines": [
+        "total 2",
+        "drwxr-xr-x fixture",
+        "-rw-r--r-- demo.txt",
+      ],
+      "prompt": "fixture ls -la",
+      "status": null,
+    }
+  `)
+})
+
+it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
+  boot()
+  await openFixtureSession()
+
+  const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('resident terminal card missing')
+  const expander = card.querySelector('button[aria-expanded]')
+  if (expander === null) throw new Error('height-cap expander missing')
+  const capped = card.querySelectorAll('[class*="_line_"]').length
+
+  fireEvent.click(expander)
+  await waitFor(() => {
+    expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
+  })
+  expect({
+    cappedLines: capped,
+    expandedLines: card.querySelectorAll('[class*="_line_"]').length,
+    expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
+    // The card sits outside the summary row's click target, so toggling it
+    // left the details panel shut.
+    detailsOpen: screen.queryByText('Input') !== null,
+  }).toMatchInlineSnapshot(`
+    {
+      "cappedLines": 8,
+      "detailsOpen": false,
+      "expandedLines": 22,
+      "expanderLabel": "收起输出",
+    }
+  `)
+})
+
+it('the details panel Output section renders the same call at full height', async () => {
+  boot()
+  await openFixtureSession()
+
+  fireEvent.click(keyedBashRow())
+  const label = await screen.findByText('Output')
+  const section = label.closest('section')
+  if (section === null) throw new Error('Output section missing')
+  const card = section.querySelector('[data-terminal]')
+  if (card === null) throw new Error('details panel Output is not a terminal card')
+
+  const chatLines = keyedBashRow().parentElement?.querySelectorAll('[class*="_line_"]').length ?? 0
+  expect({
+    ...readCard(card),
+    // The panel keeps the primitive's own allowance (16) against the chat
+    // row's 8, so it shows strictly more of the same output.
+    panelLines: card.querySelectorAll('[class*="_line_"]').length,
+    chatLines,
+  }).toMatchInlineSnapshot(`
+    {
+      "chatLines": 8,
+      "colors": [
+        "font-weight: 700;",
+        "color: var(--dsw-alias-state-success-primary);",
+        "color: var(--dsw-alias-state-error-primary);",
+        "color: var(--dsw-alias-label-tertiary);",
+      ],
+      "copy": "复制",
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 6 行输出",
+        "text": "… 其余 6 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "✗ unit                                               8.41s",
+        "",
+        "packages/client/ui-primitives/tests/terminal-block.spec.tsx",
+        "  FAIL caps output at the configured line budget",
+        "CodeBlock.tsx               98.4%    96.2%       100%         41-43",
+        "highlight.ts                100%     100%        100%         -",
+        "Pill.tsx                    100%     100%        100%         -",
+        "StateDot.tsx                100%     100%        100%         -",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+        "[exit code: 1]",
+      ],
+      "panelLines": 16,
+      "prompt": "nested pnpm run check",
+      "status": "退出码 1",
+    }
+  `)
+})
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 5c7befd8c9..401d3a76a4 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -46,6 +46,51 @@ const MARKDOWN_FIXTURE = [
 
 const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
 
+/**
+ * SGR wrapper for the terminal output sample below: authoring the escapes as
+ * `\u001b` keeps literal control bytes out of this source file.
+ * @param code - the SGR parameter (an ANSI color or attribute number).
+ * @param body - the text the attribute applies to.
+ * @returns the body wrapped in the attribute and a reset.
+ */
+function sgr(code: number, body: string): string {
+  return `\u001b[${code}m${body}\u001b[0m`
+}
+
+/**
+ * Terminal output sample for fixture turn 66, authored to carry every feature
+ * the terminal card draws that turn 60's three plain lines cannot reach:
+ * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
+ * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
+ * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
+ * height cap collapses the middle, and the trailing `[exit code: N]` marker the
+ * bash tool appends, from which the exit pill is recovered.
+ */
+const TERMINAL_OUTPUT_FIXTURE = [
+  sgr(1, 'Running 4 checks'),
+  `${sgr(32, '\u2713')} typecheck                                          1.82s`,
+  `${sgr(32, '\u2713')} lint                                               0.94s`,
+  `${sgr(32, '\u2713')} duplication                                        2.10s`,
+  `${sgr(31, '\u2717')} unit                                               8.41s`,
+  '',
+  sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
+  `  ${sgr(31, 'FAIL')} caps output at the configured line budget`,
+  '    expected 16 lines, received 24',
+  '',
+  'NAME                        LINES    BRANCHES    FUNCTIONS    UNCOVERED',
+  'TerminalBlock.tsx           100%     100%        100%         -',
+  'ansi.ts                     100%     100%        100%         -',
+  'clipboard.ts                100%     100%        100%         -',
+  'CodeBlock.tsx               98.4%    96.2%       100%         41-43',
+  'highlight.ts                100%     100%        100%         -',
+  'Pill.tsx                    100%     100%        100%         -',
+  'StateDot.tsx                100%     100%        100%         -',
+  'markdown/Markdown.tsx       100%     100%        100%         -',
+  '',
+  sgr(31, '1 of 4 checks failed'),
+  '[exit code: 1]',
+].join('\n')
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -124,7 +169,8 @@ function buildAlphaLog(): SessionEvent[] {
   }
   // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
   // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
-  // stays presenter-less as the unknown fallback.
+  // stays presenter-less as the unknown fallback. Turn 66 is the second terminal sample, carrying
+  // what turn 60's three plain lines cannot (see TERMINAL_OUTPUT_FIXTURE) through the keyed row.
   const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
     const callId = `fx-call-${turn}`
     push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -201,6 +247,13 @@ function buildAlphaLog(): SessionEvent[] {
   const callIndex = events.length - 4
   const callTime = events[callIndex]?.time as number
   events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } })
+  // Turn 66: the terminal sample turn 60's three clean lines cannot cover —
+  // ANSI SGR coloring, output past the terminal card's height cap, a nested
+  // cwd whose prompt label is its last segment, and a non-zero exit recovered
+  // from the trailing marker the bash tool appends. Named `bash`, so it also
+  // covers the keyed toolview row (turn 60's `fx-bash` covers the render-site
+  // fallback row) — the two chat-row shapes the terminal card renders in.
+  toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
   events.forEach((e, i) => { e.seq = i })
   return events as unknown as SessionEvent[]
 }
@@ -219,7 +272,11 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     return undefined
   }
   switch (name) {
+    // Both names present the same terminal card: `fx-bash` lands on the
+    // render-site fallback row, `bash` on the keyed BashRow registration, so
+    // the two chat-row shapes of one render intent are both reachable.
     case 'fx-bash':
+    case 'bash':
       return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
     case 'fx-write':
       return {
@@ -235,12 +292,24 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
   }
 }
 
+/**
+ * Recover the exit status from a trailing `[exit code: N]` marker, mirroring
+ * the real bash tool's `parseExitStatus` (this package must not depend on a
+ * tool package, so the marker contract is re-read rather than imported).
+ * @param text - the rendered result text.
+ * @returns the recovered exit code (0 when the marker is absent).
+ */
+function fixtureExitCode(text: string): number {
+  const marker = /\n\[exit code: (\d+)\]$/.exec(text)
+  return marker?.[1] === undefined ? 0 : Number(marker[1])
+}
+
 function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
   const call = presentCall(name, argsRaw)
   if (call === undefined) return undefined
   switch (call.card) {
     case 'terminal':
-      return { card: 'terminal', output: resultText, exitCode: 0 }
+      return { card: 'terminal', output: resultText, exitCode: fixtureExitCode(resultText) }
     case 'diff':
       return { card: 'diff', diffs: call.diffs }
     case 'generic':
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index c1a6828d5b..dd28591b01 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: 56a445ccfa86e0b11cf5aefc37819a30746f0739
-README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070
+README.md: 1c6912b05e259fa1f4a7096c3a2b82f9f67f5527
+README.zh.md: 157bfafd3a1157420acbb73861cd40d759228743
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 56a445ccfa..1c6912b05e 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -10,6 +10,8 @@ The view ring IS a slot: the conversation registration declares the `'conversati
 
 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 `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. 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), and the details panel resolves a selected sub-call id to its full logged args and complete output. 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. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it 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`/`openDetails`) 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 durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to 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 persistent 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.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index a7c160ecdd..157bfafd3a 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -10,6 +10,8 @@
 
 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。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,落回通用路径。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
+
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks ·  in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index 5cb2126f34..0bfde97019 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -9,6 +9,7 @@ import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
+import { terminalCardModel } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
 import { IconSparkle16 } from './IconSparkle16.tsx'
@@ -35,6 +36,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
       title={model.title}
       summary={model.summary}
       body={model.body}
+      terminal={terminalCardModel(block)}
       state={model.state}
       onOpenDetails={openDetails}
     />
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 5d44a9260e..d238218d97 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -102,9 +102,13 @@ button.leading {
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* The code variant's expanded body is the run_code program, rendered through
-   the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
-   this row's concern. */
-.codeBody {
+/* The two block-shaped expanded bodies: the code variant's run_code program
+   through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
+   command output through TerminalBlock. Both are drawn by the shared
+   primitive, so only the row's indentation is this file's concern — the margin
+   also replaces each primitive's own standalone vertical spacing with the
+   flow's row rhythm. */
+.codeBody,
+.terminalBody {
   margin: 4px 0 4px 22px;
 }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index a81c084b8e..91dcfe4a0a 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -1,13 +1,19 @@
 // ToolRow: the single-line tool summary row (figma component set 122:9479) —
 // 16px leading slot (state dot / tool icon, chevron when expanded) + title +
-// separator dot + FILL-truncated summary. Expanded body is indented gray text;
-// no inline output (full results live in the details panel). Expand state is
-// component-local view state; row click hands the selection off to the owner.
+// separator dot + FILL-truncated summary. The collapsed row is always one
+// line; the expanded body is indented gray text, the run_code program through
+// CodeBlock, or — for a call whose render intent is a terminal card — the
+// command's own output through TerminalBlock, capped at
+// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. The details
+// panel remains the full-height reading surface for the same call. Expand
+// state is component-local view state; row click hands the selection off to
+// the owner.
 
 import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+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 css from './ToolRow.module.css'
 
@@ -19,8 +25,15 @@ export interface ToolRowProps {
   icon: ReactNode
   title: string
   summary: string
-  /** Expanded-body text; null = not expandable (leading slot never toggles). */
+  /** Expanded-body text; null = no text body (`terminal` is the other body source). */
   body: string | null
+  /**
+   * Terminal-card material for a call whose render intent is a terminal card
+   * (derived by `terminalCardModel`); it replaces the text body when present.
+   * Null or absent leaves the text body, and a row with neither is not
+   * expandable (its leading slot never toggles).
+   */
+  terminal?: TerminalCardModel | null | undefined
   state: ToolRowState
   /** Makes the row itself the expand control instead of only its leading icon. */
   expandOnRowClick?: boolean | undefined
@@ -46,12 +59,18 @@ export function ToolRow({
   title,
   summary,
   body,
+  terminal,
   state,
   expandOnRowClick = false,
   onOpenDetails,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
-  const expandable = body !== null
+  const terminalBody = terminal ?? null
+  const expandable = body !== null || terminalBody !== null
+  // The text arms take the empty string for a null body: a row expandable
+  // only through its terminal material renders the terminal body instead, so
+  // this substitution never shows.
+  const text = body ?? ''
   const open = expanded && expandable
   const rowExpands = expandable && expandOnRowClick
   const toggleExpand = () => {
@@ -99,9 +118,11 @@ export function ToolRow({
           
         )}
       
- {open && (variant === 'code' - ? - :
{body}
)} + {open && (terminalBody !== null + ? + : variant === 'code' + ? + :
{text}
)} ) } diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts new file mode 100644 index 0000000000..a1b274e3f9 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts @@ -0,0 +1,83 @@ +/** + * Pure derivation of the terminal-card props from a frozen call slice: the + * `card:'terminal'` render intent the bash tool declares arrives on the + * snapshot as `callView`/`resultView`, and this is the one place that turns + * that pair into what {@link TerminalBlock} draws. Both conversation render + * sites (the chat tool row's expanded body and the details panel's Output + * section) call this, so the command, cwd, output and exit status they show + * are derived once. + * @module + */ +import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Output lines the chat row's expanded terminal body shows before collapsing + * the middle — half the primitive's own default, which the details panel + * keeps. A chat row is a summary surface inside the message flow: the flow + * must stay scannable across many calls, while the details panel is the + * single-call reading surface. A design constant of this UI's row geometry, + * not a deployment choice, so it is fixed here rather than a plugin Config + * field. + */ +export const CHAT_TERMINAL_MAX_LINES = 8 + +/** + * The {@link TerminalBlock} props this derivation owns. Picked off the + * primitive's props so the two stay in step; `home` is absent because the web + * client has no home path for the session host (a cwd renders as its last + * path segment), and `maxLines`/`className` belong to each render site. + */ +export type TerminalCardModel = Pick< + TerminalBlockProps, + 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running' +> + +/** + * Derive the terminal-card props for a tool call, or null when this call is + * not a terminal card and belongs on the generic path. + * + * The call side supplies the command and its working directory; the result + * side supplies the captured output and exit status. Three cases produce + * null, all of them the documented generic-card default: + * + * - Neither side declares `card:'terminal'` — including a `card` value this + * UI version does not know, which arrives over the wire and therefore + * cannot be trusted to be one of the compiled variants. + * - A settled call whose result view is not a terminal card: the result + * presentation decides how the settled call renders, and the bash tool + * returns a generic fenced card for an execution error or a background + * start, whose text and error styling the generic path preserves. + * + * Window truncation can drop the call head from a settled result (see + * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal + * result with no call side. That still renders: the command falls back to the + * result view's replacement title, then to an empty command (the prompt line + * draws bare), and the prompt shows no cwd. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the terminal-card props, or null for the generic path. + */ +export function terminalCardModel(block: ToolCallBlock): TerminalCardModel | null { + const call = block.callView?.card === 'terminal' ? block.callView : null + if (!('kind' in block)) { + // Running: the call view exists, the result view does not yet. + return call === null ? null : { + command: call.title, + cwd: call.cwd, + output: undefined, + exitCode: undefined, + signal: undefined, + running: true, + } + } + const result = block.resultView?.card === 'terminal' ? block.resultView : null + if (result === null) return null + return { + command: call?.title ?? result.title ?? '', + cwd: call?.cwd, + output: result.output, + exitCode: result.exitCode, + signal: result.signal, + running: false, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..0b1afa8060 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -1,7 +1,9 @@ /** * Pure row-model derivation for tool summary rows: variant classification, - * one-line summary and expanded-body text from the frozen call slice. No - * inline output ever — full results live in the details panel. + * one-line summary and expanded-body text from the frozen call slice. This + * derivation reads the call ARGUMENTS only; a call whose render intent is a + * terminal card gets its expanded body from the views instead, through + * `terminalCardModel` in terminal-card-model.ts. */ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index abdece3e25..ef4173735e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -92,3 +92,9 @@ .code[data-error] { color: var(--dsw-alias-state-error-primary); } + +/* The terminal card sits directly under its section label, so it drops the + primitive's standalone vertical margin; the section owns the spacing. */ +.terminal { + margin: 0; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 650a95f833..326e765044 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -1,47 +1,59 @@ // DetailsPanel, P-I minimal form: close button + the selected call's args and -// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in- -// trajectory are deferred (ledger). Reads the selection from the shared chat +// result — args as JSON, the result raw except for a terminal-card call, whose +// Output section is the command's terminal card. The three-段 Switch / +// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the +// selection from the shared chat // store (conversation writes, this panel reads — the cross-registration // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' -import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { terminalCardModel } from '../contract/terminal-card-model.ts' +import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' /** Full props composed by reference from the contract (automatic shares & injected share). */ export type DetailsPanelProps = DetailsSlotProps -/** Selected call material: resolved result node, or the in-flight running call's args. */ +/** + * Selected call material: the call's display name and args plus the frozen + * block slice it came from. `block` is a snapshot-cached reference, so the + * wrapper stays shallow-equal across unrelated snapshot frames; the settled / + * running split is read off it with the `'kind' in block` discrimination + * instead of duplicated as flags. + */ interface CallMaterial { name: string argsRaw: string | null - result: ToolResultNode | null - running: boolean + block: ToolCallBlock +} + +/** Material of a settled result node (native call or run_code sub-dispatch). */ +function settledMaterial(node: ToolResultNode, callId: string): CallMaterial { + return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node } +} + +/** Material of an in-flight call (native call or run_code sub-dispatch). */ +function runningMaterial(call: RunningToolCall): CallMaterial { + return { name: call.name, argsRaw: call.argsRaw, block: call } } function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null { for (const node of s.nodes) { - if (node.kind === 'tool-result' && node.callId === callId) { - return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false } - } + if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId) } const open = s.runningCalls.find(c => c.callId === callId) - if (open !== undefined) { - return { name: open.name, argsRaw: open.argsRaw, result: null, running: true } - } + if (open !== undefined) return runningMaterial(open) // run_code sub-dispatches: the native call-block shapes, so a selected // sub-row resolves through the same material as a native call — the // settled ToolResultNode form, or the RunningToolCall form mid-flight. for (const subs of s.codeDispatches.values()) { for (const sub of subs) { if (sub.callId !== callId) continue - if ('kind' in sub) { - return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false } - } - return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true } + return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub) } } return null @@ -95,15 +107,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane )}
Output
- {/* materialFor invariant: result===null ⇔ running (a settled - material always carries its result node). */} - {material.result === null - ?
运行中…
- : ( -
-                        {renderResult(material.result)}
-                      
- )} +
)} @@ -112,6 +116,29 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane ) } +/** + * The Output section's body for the selected call. A terminal-card call — a + * shell command's call/result views — renders through the shared TerminalBlock + * at the primitive's own full height allowance, so column-aligned output keeps + * its alignment and scrolls sideways instead of folding. Every other call, and + * a running call with no terminal card yet, keeps the flattened text form. + * @param props.material - the selected call's material from {@link materialFor}. + * @returns the Output section's body element. + */ +function OutputBody({ material }: { material: CallMaterial }) { + const terminal = terminalCardModel(material.block) + if (terminal !== null) return + // A settled call always carries the result node the flattened form needs; + // the running shape has no result to flatten. + if (!('kind' in material.block)) return
运行中…
+ const result = material.block + return ( +
+      {renderResult(result)}
+    
+ ) +} + /** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */ function renderResult(node: ToolResultNode): string { const parts: string[] = [] diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..450eb2301d 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,4 +1,18 @@ -/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description), + plus the terminal card the row stacks under its summary line. */ + +/* Summary line over the terminal card; the summary row keeps its own 24px + height, so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.terminal { + margin: 4px 0 4px 22px; +} .root { display: flex; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..49a5e6c2e4 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -3,10 +3,18 @@ // Product chrome matches ToolRow / Think (figma: Bash · {description}). // Child sessions keep a scoped badge so session-dimension differentiation stays // observable inside the component (no parallel registry). +// +// A bash call declares the terminal render intent, so this row also renders +// the command's own output through TerminalBlock. This row has no expand +// control (a click goes to the details panel), so its terminal body is +// resident rather than expand-gated as in ToolRow; the block's own height cap +// (CHAT_TERMINAL_MAX_LINES) and internal expander keep a long output from +// taking over the message flow. import type { Context } from 'cordis' -import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts' import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' @@ -29,26 +37,37 @@ function stateStatus(state: ToolRowState): string | null { } } -/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ +/** + * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the + * command's terminal card below it. The summary row keeps its own click target + * (the details handoff); the terminal card sits outside that row, so its copy + * and expand controls do not open the details panel. + */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) + const terminal = terminalCardModel(block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) const status = stateStatus(model.state) return ( -
- {leadingFor(model.state)} - {status !== null && {status}} - {isChild && scoped} - {model.title} - - {model.summary} +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {isChild && scoped} + {model.title} + + {model.summary} +
+ {terminal !== null && ( + + )}
) } diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 13a74548b4..4a5e80b224 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -71,6 +71,11 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', result({ call: null })).body).toBeNull() }) + it('a code row with an empty program falls back to the args JSON envelope', () => { + expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body) + .toBe('{\n "code": ""\n}') + }) + it('gives Cordis lifecycle tools action titles over their generic variants', () => { expect(toolRowModel('cordis_inspect', running({ name: 'cordis_inspect', @@ -139,6 +144,22 @@ describe('ToolRow', () => { expect(view.queryByTestId('tool-icon')).not.toBeNull() }) + it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => { + const view = render() + const row = view.getByRole('button') + fireEvent.keyDown(row, { key: 'Tab' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(row, { key: 'Enter' }) + expect(row.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(row, { key: ' ' }) + expect(row.getAttribute('aria-expanded')).toBe('false') + }) + + it('a non-expandable expandOnRowClick row exposes no row button', () => { + const view = render() + expect(view.queryByRole('button')).toBeNull() + }) + it('row click hands off to onOpenDetails; the expand toggle does not', () => { const open = vi.fn() const view = render() diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx new file mode 100644 index 0000000000..1ab75861b5 --- /dev/null +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -0,0 +1,359 @@ +// @vitest-environment jsdom +// The terminal render intent on the web side: the pure terminalCardModel +// derivation over callView/resultView, and both conversation render sites that +// consume it — the chat tool row's expanded body (GenericToolCard / BashRow) +// and the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { BashRow } from '../src/client/toolviews/bash-sample.tsx' + +afterEach(cleanup) + +/** + * Match an output line with its interior whitespace intact: the column + * alignment this card exists to preserve is exactly what the default + * whitespace-collapsing matcher would hide. + */ +const RAW = { normalizer: (text: string) => text } + +const SID = 's1' as SessionId + +const ARGS = '{"command":"ls -la","description":"List files"}' + +/** The bash tool's own call view for a foreground command. */ +const callTerminal = (over?: Partial>): ToolCallView => ({ + card: 'terminal', title: 'ls -la', description: 'List files', ...over, +}) + +/** The bash tool's own result view for a settled foreground command. */ +const resultTerminal = (over?: Partial>): ToolResultView => ({ + card: 'terminal', output: 'a.ts b.ts\nc.ts d.ts\n', exitCode: 0, ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'bash', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'bash', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false, + callView: callTerminal(), resultView: resultTerminal(), ...over, +}) + +describe('terminalCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({ + command: 'ls -la', cwd: '/projects/app', output: undefined, + exitCode: undefined, signal: undefined, running: true, + }) + }) + + it('derives a settled card from both sides, carrying the exit status', () => { + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '/projects/app' }), + resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }), + }))).toEqual({ + command: 'ls -la', cwd: '/projects/app', output: 'boom\n', + exitCode: 2, signal: undefined, running: false, + }) + expect(terminalCardModel(settled({ + resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, + }))?.signal).toBe('SIGTERM') + }) + + it('a window-truncated call side falls back to the result title, then to an empty command', () => { + // Truncation drops both the call head and its view (conversation.ts). + const truncated = { call: null, callView: null } + expect(terminalCardModel(settled({ + ...truncated, resultView: resultTerminal({ title: 'ls -la' }), + }))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) + expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined }) + }) + + it('returns null for every non-terminal call: no views, generic views, unknown cards', () => { + expect(terminalCardModel(running({ callView: null }))).toBeNull() + expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a terminal call as a generic card (the bash + // tool's own execution-error and background paths). + expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(terminalCardModel(running({ callView: future }))).toBeNull() + expect(terminalCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) +}) + +describe('chat row terminal body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({ + callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(), + }) + + it('the expanded body is the command output, capped tighter than the panel', () => { + expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the one-line summary row only, no output. + expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + expect(view.getByText('ls -la')).toBeTruthy() + // The args JSON body the generic path would have shown is gone. + expect(view.queryByText(/"command"/)).toBeNull() + }) + + it('the cap collapses a long output inside the row, expandable in place', () => { + const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`) + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('… 其余 3 行')).toBeTruthy() + expect(view.queryByText('line-5')).toBeNull() + fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' })) + expect(view.getByText('line-5')).toBeTruthy() + }) + + it('a running terminal call expands to the prompt line with no output yet', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('ls -la')).toBeTruthy() + expect(view.queryByText('复制')).toBeNull() + }) + + it('a non-terminal call keeps the args-JSON text body', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText(/"command"/)).toBeTruthy() + }) + + it('a terminal call with no args still expands, through its terminal body alone', () => { + // Empty args make the text body null; the terminal material carries the row. + const view = render() + fireEvent.click(view.container.querySelector('button')!) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + }) +}) + +describe('BashRow terminal card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, + current: undefined, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, openDetails = vi.fn()): ToolRowProps => ({ + callId: 'c1', toolName: 'bash', block, openDetails, + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the command output under the summary row, without an expand gesture', () => { + const openDetails = vi.fn() + const view = render() + expect(view.getByText('List files')).toBeTruthy() + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + // The terminal card sits outside the row's click target: copying does not + // open the details panel. + fireEvent.click(view.getByText('复制')) + expect(openDetails).not.toHaveBeenCalled() + fireEvent.click(view.getByText('List files')) + expect(openDetails).toHaveBeenCalledTimes(1) + }) + + it('a non-terminal bash call (background start) renders the summary row alone', () => { + const view = render() + expect(view.getByText('List files')).toBeTruthy() + expect(view.queryByText(/a\.ts/)).toBeNull() + }) +}) + +describe('DetailsPanel Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore( + { ids: [], byId: {}, current: undefined, phase: 'ready' }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' } + + it('renders the terminal card at full height, keeping the JSON Input section', () => { + const long = Array.from({ length: 20 }, (_, i) => `row-${i}`) + const view = mount(snapshot({ + nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })], + }), target) + expect(view.getByText(/"command"/)).toBeTruthy() + expect(view.getByText('ls -la')).toBeTruthy() + // The panel takes the primitive's own default cap (16), not the row's. + expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() + expect(view.getByText('row-0')).toBeTruthy() + }) + + it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.getByText('ls -la')).toBeTruthy() + expect(view.queryByText('运行中…')).toBeNull() + }) + + it('a running non-terminal call keeps the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target) + expect(view.getByText('运行中…')).toBeTruthy() + }) + + it('a non-terminal result keeps the flattened pre with its error styling', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, isError: true, + content: [{ type: 'text', text: 'permission denied' }], + })], + }), target) + const pre = view.container.querySelector('pre[data-error]') + expect(pre?.textContent).toBe('permission denied') + }) + + it('a run_code sub-dispatch resolves to its own terminal card', () => { + const view = mount(snapshot({ + codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]), + }), target) + expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() + }) + + it('a running run_code sub-dispatch resolves through the running material', () => { + const view = mount(snapshot({ + // The leading non-matching sub-call exercises the scan's skip. + codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]), + }), target) + expect(view.getByText('ls -la')).toBeTruthy() + }) + + it('a window-truncated call head titles the panel by callId and drops the Input section', () => { + const view = mount(snapshot({ + nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })], + }), target) + expect(view.getByText('c1')).toBeTruthy() + expect(view.queryByText('Input')).toBeNull() + expect(view.getByText('Output')).toBeTruthy() + }) + + it('scans past other nodes and other calls before reporting the call out of window', () => { + const view = mount(snapshot({ + nodes: [ + { kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] }, + settled({ callId: 'elsewhere' }), + ], + runningCalls: [running({ callId: 'also-elsewhere' })], + }), target) + expect(view.getByText('该调用不在当前窗口内')).toBeTruthy() + }) + + it('no selection at all renders the guidance line and the default title', () => { + const view = mount(snapshot(), null) + expect(view.getByText('详情')).toBeTruthy() + expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() + }) + + it('a step selection without a callId renders the guidance line too', () => { + const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 }) + expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy() + }) + + it('the close button reaches closeDetails', () => { + localStorage.clear() + const chat = createChatStore().create() + const closeDetails = vi.fn() + const snap = snapshot() + const view = render( + snap, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(createSnapshotStore( + { ids: [], byId: {}, current: undefined, phase: 'ready' }))} + useWorkspaces={bindSnapshotSelector(createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }))} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={closeDetails} + />, + ) + fireEvent.click(view.getByRole('button', { name: '关闭详情' })) + expect(closeDetails).toHaveBeenCalledTimes(1) + }) + + it('a non-text result block renders as JSON, and an empty result falls back to its error', () => { + const nonText = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'reasoning', text: 'why' }], + })], + }), target) + // Scope to the Output section: the Input section's CodeBlock renders a + //
 of its own, and it comes first in document order.
+    expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
+      .toBe('{\n  "type": "reasoning",\n  "text": "why"\n}')
+    cleanup()
+    const empty = mount(snapshot({
+      nodes: [settled({
+        callView: null, resultView: null, content: [], isError: true,
+        error: { name: 'ToolError', code: 'interrupted' },
+      })],
+    }), target)
+    expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
+  })
+})
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6162494def..1eb8a05ba0 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.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
-README.md: 58e450451ab64f69762817dfb277b8a888e2177f
-README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
+#   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
+README.md: c9c70f29804ac4e6783486595460bf07499e1dff
+README.zh.md: 254fc5ba5aef553fd447338353a0c5311ddbd98a
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 58e450451a..c9c70f2980 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -2,12 +2,16 @@
 
 English | [中文](README.zh.md)
 
-Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
+Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock), TerminalBlock. Contract: api-contracts v3 §8.
 
 ## Markdown rendering
 
 `MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
 
+## Terminal output
+
+`TerminalBlock` renders a shell command as a terminal surface: a prompt line (shortened `cwd` label plus the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
+
 ## Model Experience
 
 None, as the package renders pure React atoms in the browser; nothing here reaches a model request.
@@ -21,3 +25,4 @@ None; this package neither assembles nor sends a provider request.
 - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
 - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
 - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
+- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, while cursor movement, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 6824f3efe4..254fc5ba5a 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -2,12 +2,16 @@
 
 [English](README.md) | 中文
 
-纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input,以及 markdown 家族(MessageText/MarkdownText/JsonBlock)。契约:api-contracts v3 §8。
+纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock),以及 TerminalBlock。契约:api-contracts v3 §8。
 
 ## Markdown 渲染
 
 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
 
+## 终端输出
+
+`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签加命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
+
 ## 模型体验
 
 无。该包在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。
@@ -21,3 +25,4 @@
 - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
+- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,而光标移动、清屏和备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 9ce2bc8676..e629626129 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -21,6 +21,7 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@shikijs/langs": "^4.3.1",
+    "anser": "^2.3.5",
     "clsx": "^2.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
diff --git a/packages/client/ui-primitives/src/Pill.tsx b/packages/client/ui-primitives/src/Pill.tsx
index 76b9301972..2c3c24c1ad 100644
--- a/packages/client/ui-primitives/src/Pill.tsx
+++ b/packages/client/ui-primitives/src/Pill.tsx
@@ -12,7 +12,9 @@ import css from './Pill.module.css'
  */
 export function Pill({ active = false, className, children, onClick, ...rest }: {
   active?: boolean
-  className?: string
+  // `| undefined` so a caller can forward an optional class straight through
+  // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
+  className?: string | undefined
   children?: ReactNode
 } & ButtonHTMLAttributes) {
   if (!onClick) {
diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css
new file mode 100644
index 0000000000..8a4f4a5e60
--- /dev/null
+++ b/packages/client/ui-primitives/src/TerminalBlock.module.css
@@ -0,0 +1,101 @@
+/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
+   markdown code-block font) so a terminal card and a fenced code block read as
+   one family. The one deliberate divergence: output keeps `white-space: pre`
+   and scrolls horizontally, because folding a column-aligned command's output
+   destroys its alignment. */
+
+.block {
+  --dsl-terminal-radius: 12px;
+  --dsl-terminal-line-height: 22px;
+
+  position: relative;
+  margin: 16px 0;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-terminal-radius);
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 9px 14px;
+  background: var(--dsw-alias-markdown-code-block-banner);
+  border-top-left-radius: var(--dsl-terminal-radius);
+  border-top-right-radius: var(--dsl-terminal-radius);
+}
+
+/* The prompt row is the only element allowed to shrink; the status pill and
+   the copy control keep their intrinsic width. */
+.prompt {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  min-width: 0;
+  flex: 1;
+  font: var(--dsw-font-markdown-code-block);
+}
+
+.cwd {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.command {
+  min-width: 0;
+  color: var(--dsw-alias-label-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status {
+  flex: none;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.copyButton {
+  flex: none;
+  background-color: transparent;
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+}
+
+.output {
+  padding: 12px 14px;
+  font: var(--dsw-font-markdown-code-block);
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+
+/* No wrapping, no word-break: alignment is the payload of terminal output. */
+.line {
+  min-height: var(--dsl-terminal-line-height);
+  white-space: pre;
+}
+
+.expand {
+  display: block;
+  width: 100%;
+  padding: 0;
+  border: none;
+  background-color: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+  font: inherit;
+  text-align: left;
+}
+
+.expand:hover {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.empty {
+  padding: 12px 14px;
+  font: var(--dsw-font-markdown-code-block);
+  color: var(--dsw-alias-label-tertiary);
+}
diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx
new file mode 100644
index 0000000000..e224e2b1de
--- /dev/null
+++ b/packages/client/ui-primitives/src/TerminalBlock.tsx
@@ -0,0 +1,170 @@
+// TerminalBlock: the terminal surface for a shell command and its output —
+// prompt line (shortened cwd + command), ANSI-colored output, settled exit
+// status, and a copy control for the raw output. Output never soft-wraps:
+// column-aligned output (ls, tables, box drawing) keeps its alignment and
+// scrolls horizontally instead of folding. Colors resolve through --dsw-*
+// tokens; ANSI parsing lives in ansi.ts.
+
+import { useCallback, useMemo, useState } from 'react'
+import clsx from 'clsx'
+import { parseAnsiLines, type AnsiLine } from './ansi.ts'
+import { writeClipboard } from './clipboard.ts'
+import { Pill } from './Pill.tsx'
+import css from './TerminalBlock.module.css'
+
+/**
+ * Output lines shown before the height cap collapses the middle. Matches the
+ * TUI transcript's default tool-output budget so both front ends cut a long
+ * command's output at the same place.
+ */
+export const DEFAULT_TERMINAL_MAX_LINES = 16
+
+export interface TerminalBlockProps {
+  /** The command line, rendered verbatim after the prompt label. */
+  command: string
+  /** Working directory for the prompt label; absent renders a plain `$`. */
+  cwd?: string | undefined
+  /** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
+  home?: string | undefined
+  /** The command's output text; may contain ANSI escape sequences. */
+  output?: string | undefined
+  /** Settled exit code; a non-zero value renders the status pill. */
+  exitCode?: number | undefined
+  /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
+  signal?: string | undefined
+  /** The command is still running: the block shows the prompt line alone. */
+  running?: boolean | undefined
+  /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
+  maxLines?: number | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+/**
+ * Prompt label for a working directory: `~` for the home directory itself,
+ * otherwise the path's last segment (both separators accepted, trailing
+ * separators ignored), falling back to the path itself when it has no
+ * segment.
+ * @param cwd - the working directory path.
+ * @param home - absolute home directory, when the caller knows it.
+ * @returns the prompt label.
+ */
+function promptLabel(cwd: string, home: string | undefined): string {
+  const trimmed = cwd.replace(/[/\\]+$/, '')
+  if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
+  const segment = trimmed.split(/[/\\]/).pop()
+  return segment === undefined || segment === '' ? cwd : segment
+}
+
+/**
+ * Status pill text for a settled command, or undefined when the command
+ * settled cleanly (exit 0, no signal) and needs no pill — the same
+ * distinction the bash tool's own exit-status markers draw.
+ * @param exitCode - settled exit code, when known.
+ * @param signal - settled terminating signal name, when known.
+ * @returns the pill text, or undefined for a clean exit.
+ */
+function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
+  if (signal !== undefined) return `信号 ${signal}`
+  if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
+  return undefined
+}
+
+/**
+ * Render one parsed output line. Runs without SGR state render as bare text,
+ * so uncolored output carries no span wrappers.
+ * @param line - the line's styled runs.
+ * @returns the line's children.
+ */
+function renderLine(line: AnsiLine) {
+  return line.map((span, index) => span.style === undefined
+    ? span.text
+    : {span.text})
+}
+
+/**
+ * Render a shell command as a terminal surface.
+ * @param props - see {@link TerminalBlockProps}.
+ * @returns the terminal block element.
+ */
+export function TerminalBlock({
+  command,
+  cwd,
+  home,
+  output,
+  exitCode,
+  signal,
+  running = false,
+  maxLines = DEFAULT_TERMINAL_MAX_LINES,
+  className,
+}: TerminalBlockProps) {
+  const text = output ?? ''
+  // A command's output ends with a newline; that terminator is not an extra
+  // blank line to draw or to count against the height cap. The copy control
+  // still copies `text` untouched.
+  const lines = useMemo(() => parseAnsiLines(text.endsWith('\n') ? text.slice(0, -1) : text), [text])
+  const [expanded, setExpanded] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  const onCopy = useCallback(() => {
+    if (copied) return
+    // The raw output, never the rendered tree: the prompt line and the status
+    // pill are chrome the user did not run.
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, text])
+
+  const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
+
+  const status = statusText(exitCode, signal)
+  const empty = text.trim() === ''
+  const hidden = lines.length - maxLines
+  const capped = hidden > 0 && !expanded
+  // Same split arithmetic as the TUI transcript's collapsed tool card, so a
+  // command's head and tail slices agree between the two front ends.
+  const headLines = Math.ceil(maxLines / 2)
+  const tailLines = maxLines - headLines
+
+  return (
+    
+
+
+ {cwd === undefined ? '$' : promptLabel(cwd, home)} + {command} +
+ {status !== undefined && {status}} + {!running && !empty && ( + + )} +
+ {!running && (empty + ?
无输出
+ : ( +
+ {(capped ? lines.slice(0, headLines) : lines).map((line, index) => ( +
{renderLine(line)}
+ ))} + {hidden > 0 && ( + + )} + {capped && lines.slice(lines.length - tailLines).map((line, index) => ( +
{renderLine(line)}
+ ))} +
+ ))} +
+ ) +} diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts new file mode 100644 index 0000000000..e4e2cc13e2 --- /dev/null +++ b/packages/client/ui-primitives/src/ansi.ts @@ -0,0 +1,153 @@ +// ANSI model behind TerminalBlock: anser splits the SGR runs, this module +// resolves each run's colors and decorations into a plain style record and +// folds the runs into per-line span arrays so a height cap can slice whole +// lines. Sequences anser does not turn into color (OSC, cursor movement, +// other C0 controls) are removed before parsing so they never reach the DOM +// as literal characters. + +import Anser from 'anser' +import type { CSSProperties } from 'react' + +/** + * The subset of one anser JSON chunk this module reads. anser's own types + * declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run + * that sets no color, so the null is spelled out here. + */ +interface AnsiChunk { + /** Run text with its SGR codes already removed. */ + content: string + /** Foreground as an `r, g, b` triple, or null when the run sets none. */ + fg: string | null + /** Background as an `r, g, b` triple, or null when the run sets none. */ + bg: string | null + /** SGR attributes in effect for the run, in the order they were declared. */ + decorations: readonly string[] +} + +/** One run of terminal text; `style` is undefined for text that carries no SGR state. */ +export interface AnsiSpan { + /** The run's plain text, free of escape sequences and newlines. */ + text: string + /** Resolved inline style, or undefined when the run needs no wrapper. */ + style: CSSProperties | undefined +} + +/** The spans of one output line, in order. */ +export type AnsiLine = readonly AnsiSpan[] + +/** + * The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple + * anser emits for them, mapped onto the theme tokens that carry the same + * semantic. Black and white both resolve to the primary label color so text + * stays legible under either theme instead of matching the surface it sits + * on; bright black takes the tertiary label color (the muted-gray role). + * Magenta and cyan have no token equivalent in this design system and fall + * through to anser's literal rgb, as do all 256-palette and truecolor values. + */ +const TOKEN_BY_BASIC_RGB: Record = { + '0,0,0': 'var(--dsw-alias-label-primary)', + '255,255,255': 'var(--dsw-alias-label-primary)', + '85,85,85': 'var(--dsw-alias-label-tertiary)', + '187,0,0': 'var(--dsw-alias-state-error-primary)', + '255,85,85': 'var(--dsw-alias-state-error-secondary)', + '0,187,0': 'var(--dsw-alias-state-success-primary)', + '0,255,0': 'var(--dsw-alias-state-success-secondary)', + '187,187,0': 'var(--dsw-alias-state-warn-primary)', + '255,255,85': 'var(--dsw-alias-state-warn-secondary)', + '0,0,187': 'var(--dsw-alias-state-business-primary)', + '85,85,255': 'var(--dsw-static-blue-400)', +} + +/** + * CSS for each SGR attribute anser reports. `blink` is deliberately absent — + * animated text is not reproduced. `reverse` never arrives here: anser + * consumes it by swapping the run's foreground and background. Underline and + * strikethrough share `textDecoration`, so in a run declaring both, the + * later declaration wins. + */ +const STYLE_BY_DECORATION: Record = { + bold: { fontWeight: 700 }, + dim: { opacity: 0.7 }, + italic: { fontStyle: 'italic' }, + underline: { textDecoration: 'underline' }, + strikethrough: { textDecoration: 'line-through' }, + hidden: { visibility: 'hidden' }, +} + +/** OSC strings (window title, hyperlinks), with or without their terminator. */ +const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g + +/** Escape sequences other than CSI: charset selection, single-shift, reset. */ +const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g + +/** C0 controls with no display meaning here; tab, newline and ESC survive for layout and anser's CSI split. */ +const INERT_CONTROL = /[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]/g + +/** + * Apply carriage-return redraws: within a line, only the text after the last + * `\r` survives, which is what a terminal shows for progress output. A `\r` + * that only terminates a CRLF line is dropped first so those lines keep + * their text. SGR codes preceding a dropped redraw are dropped with it. + * @param text - output text, already free of OSC and non-CSI escapes. + * @returns the text with each line reduced to its final redraw. + */ +function applyCarriageReturns(text: string): string { + return text.split('\n').map((raw) => { + const line = raw.replace(/\r+$/, '') + return line.slice(line.lastIndexOf('\r') + 1) + }).join('\n') +} + +/** + * Remove every escape sequence and control character that carries no color, + * leaving CSI sequences for anser and `\n`/`\t` for layout. + * @param text - raw command output. + * @returns text whose only remaining escapes are CSI sequences. + */ +function sanitize(text: string): string { + const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '') + return applyCarriageReturns(escaped).replace(INERT_CONTROL, '') +} + +/** + * Resolve one run's colors and decorations. + * @param chunk - the anser chunk to style. + * @returns the run's inline style, or undefined when it carries no SGR state. + */ +function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined { + const style: CSSProperties = {} + const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})` + if (background !== undefined) style.backgroundColor = background + if (chunk.fg !== null) { + const literal = `rgb(${chunk.fg})` + // A run that paints its own background keeps anser's literal pair so the + // authored foreground/background contrast survives; a foreground-only run + // maps onto a theme token, which adapts to light and dark surfaces. + style.color = background === undefined + ? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal + : literal + } + for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration]) + return Object.keys(style).length === 0 ? undefined : style +} + +/** + * Parse command output into styled spans grouped by line. + * @param text - raw output text, which may contain ANSI escape sequences. + * @returns one entry per output line (always at least one, possibly empty). + */ +export function parseAnsiLines(text: string): AnsiLine[] { + let current: AnsiSpan[] = [] + const lines: AnsiSpan[][] = [current] + for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) { + const style = resolveStyle(chunk) + for (const [index, part] of chunk.content.split('\n').entries()) { + if (index > 0) { + current = [] + lines.push(current) + } + if (part !== '') current.push({ text: part, style }) + } + } + return lines +} diff --git a/packages/client/ui-primitives/src/clipboard.ts b/packages/client/ui-primitives/src/clipboard.ts new file mode 100644 index 0000000000..c01e802f31 --- /dev/null +++ b/packages/client/ui-primitives/src/clipboard.ts @@ -0,0 +1,48 @@ +// Package-internal clipboard write, shared by every copy control in this +// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the +// public surface: consumers get the components, not the host detection. + +/** + * Write text to the host clipboard, preferring the async Clipboard API and + * falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts) + * that omit it. + * @param text - the exact text to place on the clipboard. + * @returns true only when the host accepted the write. + */ +export async function writeClipboard(text: string): Promise { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Denied permissions / iframe policy — do not claim success. + return false + } + } + // jsdom and older hosts: best-effort execCommand path when present. + // execCommand('copy') is the only clipboard fallback where the async API + // is missing; deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return false + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + return exec('copy') + } catch { + return false + } finally { + el.remove() + } + /* eslint-enable @typescript-eslint/no-deprecated */ +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 330a3145bf..dca6374fb8 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -17,6 +17,8 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' +export type { TerminalBlockProps } from './TerminalBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index de6a478af4..9c7e968053 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import clsx from 'clsx' +import { writeClipboard } from '../clipboard.ts' import { highlightToHtml } from './highlight.ts' import css from './CodeBlock.module.css' @@ -18,45 +19,6 @@ export interface CodeBlockProps { className?: string | undefined } -/** @returns true only when the host accepted the write. */ -async function writeClipboard(text: string): Promise { - // lib.dom types clipboard non-optional, but insecure contexts omit it — - // that runtime gap is exactly what this guard detects. - /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - // Denied permissions / iframe policy — do not claim success. - return false - } - } - // jsdom and older hosts: best-effort execCommand path when present. - // execCommand('copy') is the only clipboard fallback where the async API - // is missing; deprecated but deliberately retained. - /* eslint-disable @typescript-eslint/no-deprecated */ - const exec = typeof document.execCommand === 'function' - ? document.execCommand.bind(document) - : undefined - if (exec === undefined) return false - const el = document.createElement('textarea') - el.value = text - el.setAttribute('readonly', '') - el.style.position = 'fixed' - el.style.left = '-9999px' - document.body.appendChild(el) - el.select() - try { - return exec('copy') - } catch { - return false - } finally { - el.remove() - } - /* eslint-enable @typescript-eslint/no-deprecated */ -} - export function CodeBlock({ code, lang, className }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang]) diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts new file mode 100644 index 0000000000..57a29d4f33 --- /dev/null +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -0,0 +1,188 @@ +// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs +// resolved into inline styles and folded into per-line span arrays, with every +// escape and control character that carries no color removed first. The DOM +// side of the same model (which runs get a span wrapper) is in +// terminal-block.spec.tsx. + +import { describe, expect, it } from 'vitest' +import { parseAnsiLines } from '../src/ansi.ts' + +const ESC = '\u001b' + +/** Paint `text` with the SGR `codes`, then reset. */ +function sgr(codes: string, text: string): string { + return `${ESC}[${codes}m${text}${ESC}[0m` +} + +/** The single span of a single-line, single-run parse. */ +function onlySpan(text: string) { + const lines = parseAnsiLines(text) + expect(lines).toHaveLength(1) + expect(lines[0]).toHaveLength(1) + return lines[0]![0]! +} + +describe('parseAnsiLines: text without SGR state', () => { + it('leaves plain text as one unstyled span', () => { + expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]]) + }) + + it('returns exactly one empty line for empty input', () => { + expect(parseAnsiLines('')).toEqual([[]]) + }) + + it('splits a multi-line run and drops the empty line between two blocks', () => { + expect(parseAnsiLines('a\n\nb')).toEqual([ + [{ text: 'a', style: undefined }], + [], + [{ text: 'b', style: undefined }], + ]) + }) + + it('keeps tabs, which the terminal surface needs for column layout', () => { + expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined }) + }) +}) + +describe('parseAnsiLines: basic colors mapped onto theme tokens', () => { + it.each<[string, string, string]>([ + ['30', 'black', 'var(--dsw-alias-label-primary)'], + ['37', 'white', 'var(--dsw-alias-label-primary)'], + ['90', 'bright black', 'var(--dsw-alias-label-tertiary)'], + ['31', 'red', 'var(--dsw-alias-state-error-primary)'], + ['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'], + ['32', 'green', 'var(--dsw-alias-state-success-primary)'], + ['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'], + ['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'], + ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'], + ['34', 'blue', 'var(--dsw-alias-state-business-primary)'], + ['94', 'bright blue', 'var(--dsw-static-blue-400)'], + ])('SGR %s (%s) resolves to %s', (code, _name, token) => { + expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } }) + }) +}) + +describe('parseAnsiLines: colors with no token equivalent', () => { + it.each<[string, string, string]>([ + ['35', 'magenta', 'rgb(187, 0, 187)'], + ['36', 'cyan', 'rgb(0, 187, 187)'], + ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'], + ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'], + ])('SGR %s (%s) falls through to %s', (code, _name, literal) => { + expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal }) + }) +}) + +describe('parseAnsiLines: backgrounds', () => { + it('sets backgroundColor for a background-only run', () => { + expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' }) + }) + + it('keeps the literal foreground when the run paints its own background', () => { + expect(onlySpan(sgr('41;37', 'x')).style).toEqual({ + backgroundColor: 'rgb(187, 0, 0)', + color: 'rgb(255,255,255)', + }) + }) + + it('renders reverse video as the swapped pair anser reports', () => { + expect(onlySpan(sgr('31;7', 'x')).style).toEqual({ + backgroundColor: 'rgb(187, 0, 0)', + color: 'rgb(0, 0, 0)', + }) + }) +}) + +describe('parseAnsiLines: decorations', () => { + it.each<[string, string, Record]>([ + ['1', 'bold', { fontWeight: 700 }], + ['2', 'dim', { opacity: 0.7 }], + ['3', 'italic', { fontStyle: 'italic' }], + ['4', 'underline', { textDecoration: 'underline' }], + ['9', 'strikethrough', { textDecoration: 'line-through' }], + ['8', 'hidden', { visibility: 'hidden' }], + ])('SGR %s (%s) resolves to %o', (code, _name, style) => { + expect(onlySpan(sgr(code, 'x')).style).toEqual(style) + }) + + it('lets the later textDecoration win when a run declares underline and strikethrough', () => { + expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' }) + expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' }) + }) + + it('combines a color with several decorations in one style', () => { + expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({ + color: 'var(--dsw-alias-state-error-primary)', + fontWeight: 700, + fontStyle: 'italic', + }) + }) + + it('reproduces no animation for blink, leaving the run unstyled', () => { + expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined }) + }) +}) + +describe('parseAnsiLines: sequences that carry no color', () => { + it('removes an OSC string with its BEL terminator', () => { + expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined }) + }) + + it('removes an OSC string terminated by ST', () => { + expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined }) + }) + + it('removes non-CSI escapes such as charset selection and reset', () => { + expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined }) + }) + + it('removes inert C0 controls', () => { + expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined }) + }) + + it('keeps CSI sequences that only move the cursor out of the text', () => { + expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined }) + }) +}) + +describe('parseAnsiLines: carriage returns', () => { + it('keeps only the last redraw of a line', () => { + expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined }) + }) + + it('drops the SGR codes that preceded a discarded redraw', () => { + expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined }) + }) + + it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => { + expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([ + [{ text: 'a', style: undefined }], + [{ text: 'b', style: undefined }], + [], + ]) + }) + + it('applies the redraw per line, not across the whole text', () => { + expect(parseAnsiLines('one\rtwo\nthree')).toEqual([ + [{ text: 'two', style: undefined }], + [{ text: 'three', style: undefined }], + ]) + }) +}) + +describe('parseAnsiLines: runs spanning lines', () => { + it('carries one run\'s style onto every line it covers', () => { + expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([ + [{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + [{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }], + ]) + }) + + it('keeps several runs of one line in order', () => { + expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[ + { text: 'plain', style: undefined }, + { text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } }, + { text: 'tail', style: undefined }, + ]]) + }) +}) diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx new file mode 100644 index 0000000000..87e153a741 --- /dev/null +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -0,0 +1,315 @@ +// @vitest-environment jsdom +// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled +// arms, the exit-status pill, the head/tail height cap and its expand control, +// and the copy control writing the raw output on both the accepted and the +// refused clipboard paths. writeClipboard's own return contract is pinned here +// too, since it is the seam both copy controls in this package share; the +// resolution of ANSI runs into styles is pinned in ansi.spec.ts, so only its +// DOM consequence (which runs get a span wrapper) is asserted here. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts' +import { writeClipboard } from '../src/clipboard.ts' + +const ESC = '\u001b' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered output rows, one string per visible line (CSS-module class prefix). */ +function outputLines(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered output lines, without the terminating newline. */ +function body(count: number): string { + return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n') +} + +describe('TerminalBlock prompt label', () => { + it('collapses the home directory itself to ~', () => { + render() + expect(screen.getByText('~')).toBeTruthy() + }) + + it('shows only the last segment below home', () => { + render() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('ignores trailing separators on both the cwd and home', () => { + const view = render() + expect(view.getByText('~')).toBeTruthy() + view.rerender() + expect(view.getByText('~')).toBeTruthy() + }) + + it('drops trailing separators before taking the last segment', () => { + render() + expect(screen.getByText('Documents')).toBeTruthy() + }) + + it('takes the last segment when no home is known', () => { + render() + expect(screen.getByText('Projects')).toBeTruthy() + }) + + it('collapses a backslash home path to ~', () => { + render() + expect(screen.getByText('~')).toBeTruthy() + }) + + it('falls back to the raw path when it has no segment', () => { + render() + expect(screen.getByText('/')).toBeTruthy() + }) + + it('renders a plain $ with no cwd', () => { + render() + expect(screen.getByText('$')).toBeTruthy() + }) + + it('renders the command verbatim after the label', () => { + render() + expect(screen.getByText('git log --oneline | head -3')).toBeTruthy() + }) +}) + +describe('TerminalBlock states', () => { + it('running shows the command line only: no output, no placeholder, no copy', () => { + const view = render() + expect(view.getByText('sleep 5')).toBeTruthy() + expect(view.queryByText('partial')).toBeNull() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + }) + + it('running still shows a settled-looking status pill when one is supplied', () => { + render() + expect(screen.getByText('信号 SIGINT')).toBeTruthy() + }) + + it('settled with whitespace-only output shows the dimmed placeholder', () => { + const view = render() + expect(view.getByText('无输出')).toBeTruthy() + expect(view.queryByRole('button', { name: '复制' })).toBeNull() + }) + + it('settled with absent output shows the placeholder', () => { + render() + expect(screen.getByText('无输出')).toBeTruthy() + }) + + it('settled with an empty string shows the placeholder', () => { + render() + expect(screen.getByText('无输出')).toBeTruthy() + }) + + it('merges className onto the wrapper', () => { + const view = render() + expect(view.container.firstElementChild?.classList.contains('x')).toBe(true) + expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false) + }) + + it('drops the output text terminator instead of drawing a blank line', () => { + const view = render() + expect(outputLines(view.container)).toEqual(['a', 'b']) + }) + + it('keeps a genuinely blank final line when the output ends with two newlines', () => { + const view = render() + expect(outputLines(view.container)).toEqual(['a', 'b', '']) + }) + + it('renders ANSI runs as styled spans and plain text bare', () => { + const view = render() + const span = view.container.querySelector('span[style]') + expect(span?.textContent).toBe('bad') + expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary') + expect(outputLines(view.container)).toEqual(['bad ok']) + }) + + it('renders uncolored output with no span wrappers at all', () => { + const view = render() + expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0) + }) +}) + +describe('TerminalBlock status pill', () => { + it('renders no pill for a clean exit', () => { + const view = render() + expect(view.queryByText(/退出码|信号/u)).toBeNull() + }) + + it('renders no pill while the exit status is unknown', () => { + const view = render() + expect(view.queryByText(/退出码|信号/u)).toBeNull() + }) + + it('renders the exit-code pill for a non-zero exit', () => { + render() + expect(screen.getByText('退出码 1')).toBeTruthy() + }) + + it('renders the signal pill, which outranks the exit code', () => { + render() + expect(screen.getByText('信号 SIGKILL')).toBeTruthy() + expect(screen.queryByText(/退出码/u)).toBeNull() + }) +}) + +describe('TerminalBlock height cap', () => { + it('renders every line and no expand control under the cap', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-expanded]')).toBeNull() + }) + + it('does not count the output terminator against the cap', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(4) + expect(view.container.querySelector('[aria-expanded]')).toBeNull() + }) + + it('slices head and tail over the cap and expands on click', () => { + const view = render() + // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden. + expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10']) + const toggle = view.getByRole('button', { name: '展开其余 6 行输出' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toBe('… 其余 6 行') + + fireEvent.click(toggle) + expect(outputLines(view.container)).toHaveLength(10) + const collapse = view.getByRole('button', { name: '收起输出' }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(collapse.textContent).toBe('收起') + + fireEvent.click(collapse) + expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10']) + }) + + it('renders the head slice alone when the cap leaves no tail', () => { + const view = render() + expect(outputLines(view.container)).toEqual(['line 1']) + expect(view.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy() + }) + + it('caps at the documented default when maxLines is absent', () => { + const view = render() + expect(outputLines(view.container)).toHaveLength(DEFAULT_TERMINAL_MAX_LINES) + expect(view.getByRole('button', { name: '展开其余 1 行输出' })).toBeTruthy() + }) +}) + +describe('TerminalBlock copy', () => { + it('copies the raw output, never the prompt line or the pill', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const output = `${ESC}[31mbad${ESC}[39m\n` + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + // Escape codes, the newline terminator, and nothing of the chrome around them. + expect(writeText).toHaveBeenCalledWith(output) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // While the ok label is showing, further clicks are no-ops. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('copies the whole output while the height cap hides its middle', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const output = `${body(10)}\n` + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith(output) + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when the host refuses the write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) +}) + +describe('writeClipboard', () => { + it('reports true after the async Clipboard API accepts the exact text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + await expect(writeClipboard('payload')).resolves.toBe(true) + expect(writeText).toHaveBeenCalledWith('payload') + }) + + it('reports false when the Clipboard API rejects', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('selects a detached textarea for the execCommand fallback and removes it after', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + let selected: string | undefined + const exec = vi.fn(() => { + selected = document.querySelector('textarea[readonly]')?.value + return true + }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: exec }) + await expect(writeClipboard('payload')).resolves.toBe(true) + expect(exec).toHaveBeenCalledWith('copy') + expect(selected).toBe('payload') + expect(document.querySelector('textarea')).toBeNull() + }) + + it('reports execCommand\'s own refusal verbatim', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn(() => false) }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('reports false and still removes the textarea when execCommand throws', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + await expect(writeClipboard('payload')).resolves.toBe(false) + expect(document.querySelector('textarea')).toBeNull() + }) + + it('reports false on a host with neither clipboard path', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) + + it('reports false when navigator.clipboard exists without writeText', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {} }) + Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined }) + await expect(writeClipboard('payload')).resolves.toBe(false) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9538051cd..53791fe2fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1075,6 +1075,9 @@ importers: '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 + anser: + specifier: ^2.3.5 + version: 2.3.5 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -7856,6 +7859,9 @@ packages: resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==} engines: {node: '>= 14.0.0'} + anser@2.3.5: + resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -12840,6 +12846,8 @@ snapshots: '@algolia/requester-fetch': 5.55.2 '@algolia/requester-node-http': 5.55.2 + anser@2.3.5: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} From d1ce22e7ad142f99dba86f94de0c353b43bc5a57 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:04:59 +0800 Subject: [PATCH 005/153] doc(packages): add the host/ and client/ group READMEs and table rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both web-GUI groups shipped without the group README that the packages table names as each group's canonical package/ctx-key map, and without rows in that table. Adds both bilingual pairs, the two table rows (ceiling 835→870: two genuinely new product groups joined the canonical table at minimal row width), and fixes webserver README drift (WebServerService/ctx.webServer → HttpServerService/ctx.httpServer, matching src/index.ts). --- packages/README.i18n.yaml | 4 +-- packages/README.md | 2 ++ packages/README.zh.md | 2 ++ packages/client/README.i18n.yaml | 6 +++++ packages/client/README.md | 34 ++++++++++++++++++++++++ packages/client/README.zh.md | 34 ++++++++++++++++++++++++ packages/host/README.i18n.yaml | 6 +++++ packages/host/README.md | 12 +++++++++ packages/host/README.zh.md | 12 +++++++++ packages/host/webserver/README.i18n.yaml | 6 ++--- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- scripts/doc-budgets.manifest.json | 2 +- 13 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 packages/client/README.i18n.yaml create mode 100644 packages/client/README.md create mode 100644 packages/client/README.zh.md create mode 100644 packages/host/README.i18n.yaml create mode 100644 packages/host/README.md create mode 100644 packages/host/README.zh.md diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 5dd168f03e..2415b8c578 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: d16e395a42e491461c0862227205931894c27e39 -README.zh.md: 3fb4181ce7ae7b0d79a13ca4358b9df39d83ef1f +README.md: ed1c2b65ef53793baf905791179b474c2f696637 +README.zh.md: 14c91a5e1899e1df6c9ceedb274423a4b8e12461 diff --git a/packages/README.md b/packages/README.md index d16e395a42..ed1c2b65ef 100644 --- a/packages/README.md +++ b/packages/README.md @@ -43,6 +43,8 @@ Packages live at `packages///`; groups are containers, while names r | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface | | [`ui/`](ui/README.md) | Human/client integrations: TUI and JSON-RPC, approval/interaction seams, ask-user tool | Product — stable surface | +| [`host/`](host/README.md) | Web-GUI host half: shared API gateway + HTTP route server | Product — stable surface | +| [`client/`](client/README.md) | Web-GUI browser half: shell, wire consumer, object services, slot system, `ui-*` feature plugins | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 3fb4181ce7..14c91a5e18 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -43,6 +43,8 @@ | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | | [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 | | [`ui/`](ui/README.md) | 人类/客户端集成:TUI 与 JSON-RPC、批准/交互 seam、用户问答工具 | 产品:稳定表面 | +| [`host/`](host/README.md) | web GUI 宿主半侧:共享 API 网关 + HTTP 路由服务器 | 产品:稳定表面 | +| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议消费层、对象服务、slot 系统、`ui-*` 特性插件 | 产品:稳定表面 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + TUI/CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml new file mode 100644 index 0000000000..0bac8a9924 --- /dev/null +++ b/packages/client/README.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 packages/client/README.md +README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b +README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8 diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000000..b111d67fa4 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,34 @@ +# client/ — web-GUI browser half + +English | [中文](README.zh.md) + +The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-`. + +| Package | Role | ctx key / slot | +|---|---|---| +| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) | +| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) | +| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) | +| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` | +| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) | +| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` | +| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) | +| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) | +| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) | +| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) | +| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) | +| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) | +| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` | +| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` | +| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) | +| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) | +| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` | +| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) | +| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) | +| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) | +| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) | + +Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer. diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md new file mode 100644 index 0000000000..b498008eb8 --- /dev/null +++ b/packages/client/README.zh.md @@ -0,0 +1,34 @@ +# client/ — web GUI 浏览器半侧 + +[English](README.md) | 中文 + +dsh web GUI 的浏览器侧:shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-`。 + +| 包 | 角色 | ctx 键/slot | +|---|---|---| +| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) | +| `modules/` | 客户端模块系统:Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) | +| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) | +| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环),node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` | +| `runtime/` | 客户端 cordis 启动与无 React 对象服务:slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` | +| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) | +| `locale/` | 浏览器语言偏好(`zh`/`en`)与 ns×locale 词典注册表 | `ctx.locale` | +| `ui-slots/` | slot 注册表纯核心:SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) | +| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light`/`dark`/`system`) | `ctx.theme` | +| `ui-primitives/` | 纯 React 原子:图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) | +| `ui-layout/` | shell 三栏 AppFrame;声明 `sidebar`/`conversation`/`details`/`conversation.empty` | `ctx.layout` | +| `ui-sidebar/` | 侧栏 shell:Workspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | (slot 宿主) | +| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces`、`conversation.hero.workspace`) | +| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | (slot 宿主) | +| `ui-trajectory/` | Trajectory/Waterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view`) | +| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` | +| `ui-slash/` | 输入触发流水线:光标下的 `/` 与 `@` 检测、分组候选菜单、源名册 | `ctx.slash` | +| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash`) | +| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash`) | +| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` | +| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer`) | +| `ui-settings/` | 设置 shell:触发 chrome + 模态面板;声明 `settings.*` slot | (slot 宿主) | +| `ui-settings-general/` | 设置的无主文案:chrome 内容 + General 分区骨架 | (填充 `settings.*`) | +| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section`) | + +特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。 diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml new file mode 100644 index 0000000000..b406dd394e --- /dev/null +++ b/packages/host/README.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 packages/host/README.md +README.md: 61e50cb64b95932085342b01a22d029cf8d5a228 +README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc diff --git a/packages/host/README.md b/packages/host/README.md new file mode 100644 index 0000000000..61e50cb64b --- /dev/null +++ b/packages/host/README.md @@ -0,0 +1,12 @@ +# host/ — web-GUI host half + +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. + +| Package | Role | ctx key | +|---|---|---| +| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` | +| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | + +`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md new file mode 100644 index 0000000000..3109eccf89 --- /dev/null +++ b/packages/host/README.zh.md @@ -0,0 +1,12 @@ +# host/ — web GUI 宿主半侧 + +[English](README.md) | 中文 + +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` | +| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | + +`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 9addd33a69..40d5fcf9d3 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.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 -README.md: c589c32c4e641e188f19ac6c5ad2e88e3eb79be3 -README.zh.md: 767195086b90a76160d87865caebf514ca75b0e3 +# pnpm run verify-translation-pairing --write packages/host/webserver/README.md +README.md: e715e4452ddb808f36e6b097eee0fda7b8d0bfb0 +README.zh.md: 05e7e10d7815c8f26bb90597b38b7c6b83a86dbc diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index c589c32c4e..e715e4452d 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Plain HTTP route-registration plugin (default-exported `WebServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.webServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. +Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` — `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, and `port` reads the listening port (the OS-assigned value when `port` is 0). The match order is fixed — exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics. The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell. diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 767195086b..05e7e10d78 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -朴素的 HTTP 路由注册插件(默认导出 `WebServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.webServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 +朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由。`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist,并遵循固定语义:越出 dist 根目录的遍历返回 403,任何未命中项都以 HTTP 200 回退到 `index.html`(SPA 路由),未知扩展名按 octet-stream 提供,GET/HEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。 该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR(热模块替换)事件流则是 modules/hmr 插件的路由。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4cae854147..ea8cb380c9 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -7,5 +7,5 @@ "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 675, - "packages/README.md": 835 + "packages/README.md": 870 } From 01eea07bab1b73dac380919c8dafa94fa5adc9ba Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:40:02 +0800 Subject: [PATCH 006/153] fix(connection): keep LAN serving working under the /api browser-trust fence Markerless requests pass on any Host (a non-browser sender is the principal and forges headers anyway); browser Host matching gains port-less entries and WHATWG normalization; dsh derives LAN IP-literal authorities for an all-interfaces bind and web grows --trusted-host for named ones. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 4 +- ...026-07-28-api-browser-trust-boundary.zh.md | 4 +- apps/cli/README.i18n.yaml | 6 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 41 +++++++++++++ apps/cli/src/args.ts | 5 ++ apps/cli/src/bin.ts | 2 +- apps/cli/src/web.ts | 19 +++---- apps/cli/tests/args.spec.ts | 3 + apps/cli/tests/trusted-hosts.spec.ts | 40 +++++++++++++ docs/config-catalog.md | 9 +-- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 57 +++++++++++++------ packages/client/connection/src/index.ts | 9 +-- .../tests/api-request-trust.spec.ts | 45 ++++++++++----- .../client/connection/tests/node-half.spec.ts | 5 ++ 20 files changed, 199 insertions(+), 66 deletions(-) create mode 100644 apps/cli/tests/trusted-hosts.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 11973b755f..68473ee893 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: c620f1a65e3890bbd2580415e55b25436fefe36e -2026-07-28-api-browser-trust-boundary.zh.md: 0452eff1017b2f70a00e67c5cfce8dba3a840539 +2026-07-28-api-browser-trust-boundary.md: 45a332fcfe59fb930a85cfc595dd02c5fe12a5d7 +2026-07-28-api-browser-trust-boundary.zh.md: 731d6c81f71a2f50b716e52e278f2c53ad62a04b diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index c620f1a65e..45a332fcfe 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: `Host` must be loopback or an exact `host[:port]` from the plugin's `trustedHosts` config (rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. Requests without browser markers pass — a non-browser client is the principal itself, not a deputy. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. `host.pickDirectory` loses its bespoke guard and rides the same fence. Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. @@ -26,6 +26,6 @@ Two boundaries stay deliberately out of scope: reachability is the webserver bin ## Consequences - Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. -- Non-loopback deployments must declare their serving authorities in `trustedHosts` or browsers are refused; plain curl-shape automation is unaffected either way. +- Non-loopback deployments must have their serving authorities trusted or browsers are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Plain curl-shape automation is unaffected everywhere. - Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). - The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 0452eff101..731d6c81f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:`Host` 必须是回环地址,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不带浏览器标头的请求放行——非浏览器客户端是委托人本人,不是代理人。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 @@ -26,6 +26,6 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署必须在 `trustedHosts` 中声明服务权威,否则浏览器会被拒绝;curl 形态的自动化不受影响。 +- 非回环部署的服务权威必须获得信任,否则浏览器会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。curl 形态的自动化在任何地方都不受影响。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 - 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..3322014ad5 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.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 -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: f5e52382fb86ecd6b96b84b90b310514285f1904 +README.zh.md: b2089c67d751a25c6443a5e15b53266c728e5156 diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..f5e52382fb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -4,7 +4,7 @@ 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. +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. The TUI surface: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..b2089c67d7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -4,7 +4,7 @@ `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` 值)和有效性的唯一真源,并在启动时拒绝错误值。 +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 无需任何标志即可使用。 TUI 界面: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 344c66e2b3..1002b45660 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -9,6 +9,7 @@ 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' @@ -25,6 +26,38 @@ import type {} from '@deepseek-ai/dsh-host-webserver' const PROFILE_DIR = '.dsh-tmp-profile' const PROFILE_FILE = 'config.json' +/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */ +export const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Non-internal IPv4 interface addresses of this machine — the IP-literal + * authorities an all-interfaces bind is reachable by on the LAN. + * @returns the addresses in interface order (possibly empty). + */ +export function lanIPv4Addresses(): string[] { + return Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) +} + +/** + * Authorities the /api browser-trust fence must accept for one invocation: + * the machine's LAN IP literals when the effective bind is all-interfaces + * (advertised by the printed LAN URL, so they must not answer 403), followed + * by the explicit extras. Derived entries are port-less IP literals — DNS + * rebinding needs an attacker-controlled name, so an IP-literal Host is safe + * on any port, and the bound port may be OS-assigned, unknowable pre-boot. + * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). + * @param extra - `--trusted-host` values, in argv order. + * @returns the connection row's `trustedHosts` value (possibly empty). + */ +export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] { + return [ + ...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [], + ...extra, + ] +} + /** One profile-json key mapped onto a yml row's config field. */ interface ProfileMapping { jsonPath: string @@ -79,6 +112,8 @@ export interface AppCLIEntryOptions { port?: number /** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */ workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */ + trustedHosts?: string[] } /** @@ -152,6 +187,12 @@ export class AppCLIEntry { if (this.options.port !== undefined) put('webserver', 'port', this.options.port) if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) + // Source 2b: authorities for the /api browser-trust fence (rationale on + // resolveTrustedHosts). + const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host + const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) + // Source 3: the frontend dist — an assembly fact of this app, never yml // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 9fd0f4d9bf..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -40,6 +40,8 @@ interface WebInvocation { port?: number dev: boolean workspaceRoot?: string + /** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */ + trustedHosts?: string[] } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ @@ -51,6 +53,7 @@ interface WebOptions { port?: string dev?: boolean workspaceRoot?: string + trustedHost?: string[] } /** @@ -66,6 +69,7 @@ function resolveWeb(options: WebOptions): WebInvocation { ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } } @@ -117,6 +121,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .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('--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 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index f9e1eefc9b..88dbece55a 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) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) break } case 'headless': { diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 31282c8f5f..3d7fc29ab4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -6,17 +6,14 @@ * gates them at boot. */ -import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' -import { AppCLIEntry } from './app-cli-entry.ts' +import { ALL_INTERFACES_HOST, AppCLIEntry, lanIPv4Addresses } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Display-only mirrors of the webserver schema's allowed hosts: the loopback -// address the local URL always prints, and the all-interfaces value that gates -// LAN-address discovery. Not a source of truth — the schema is. +// 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. const LOOPBACK_HOST = '127.0.0.1' -const ALL_INTERFACES_HOST = '0.0.0.0' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed @@ -25,12 +22,14 @@ const ALL_INTERFACES_HOST = '0.0.0.0' * @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. */ export async function runWeb( host: string | undefined, port: number | undefined, dev: boolean, workspaceRoot: string | undefined, + trustedHosts: string[] | undefined, ): Promise { const entry = new AppCLIEntry({ configPath: CONFIG_PATH, @@ -38,6 +37,7 @@ export async function runWeb( ...host !== undefined && { host }, ...port !== undefined && { port }, ...workspaceRoot !== undefined && { workspaceRoot }, + ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() @@ -48,12 +48,9 @@ export async function runWeb( void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = host === ALL_INTERFACES_HOST - ? Object.values(networkInterfaces()).flat() - .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - : undefined + const lanCandidate = host === ALL_INTERFACES_HOST ? lanIPv4Addresses()[0] : undefined const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` - console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`) + console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) process.on('SIGTERM', () => { shutdown(0) }) process.on('SIGINT', () => { shutdown(130) }) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 052e96e9a0..45830eee30 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -35,6 +35,9 @@ describe('parseDshArgs', () => { // 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' }) + // --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'] }) }) it('exits nonzero instead of silently starting fresh or dropping inputs', () => { diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts new file mode 100644 index 0000000000..1ed0f602b8 --- /dev/null +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -0,0 +1,40 @@ +/** LAN-authority derivation for the /api browser-trust fence (`resolveTrustedHosts`). */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { lanIPv4Addresses, resolveTrustedHosts } from '../src/app-cli-entry.ts' + +vi.mock('node:os', () => ({ + networkInterfaces: () => ({ + lo0: [ + { family: 'IPv4', internal: true, address: '127.0.0.1' }, + ], + en0: [ + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv4', internal: false, address: '192.168.1.5' }, + ], + en1: [ + { family: 'IPv4', internal: false, address: '10.0.0.7' }, + ], + utun0: undefined, + }), +})) + +afterEach(() => { vi.restoreAllMocks() }) + +describe('lanIPv4Addresses', () => { + it('returns only non-internal IPv4 addresses, in interface order', () => { + expect(lanIPv4Addresses()).toEqual(['192.168.1.5', '10.0.0.7']) + }) +}) + +describe('resolveTrustedHosts', () => { + it('derives port-less LAN IP literals for an all-interfaces bind, ahead of the extras', () => { + expect(resolveTrustedHosts('0.0.0.0', ['harness.internal:3080'])) + .toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) + + it('derives nothing for a loopback or unresolved bind — extras alone stand', () => { + expect(resolveTrustedHosts('127.0.0.1', [])).toEqual([]) + expect(resolveTrustedHosts(undefined, ['lab.internal'])).toEqual(['lab.internal']) + }) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d30aff1f02..6155c418e0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -278,10 +278,11 @@ Requires: `httpServer` · `apiProxy` /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { /** - * Exact `host[:port]` authorities this deployment serves beyond loopback. - * The /api trust fence refuses any request whose Host is neither loopback - * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare - * the names it is reached by. + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * browser request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 5c5a825a27..8310adac83 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: a301b85d707d17d1e8159655b540556eee5c9d83 -README.zh.md: 88d7aa806167033308a9913053f621ecea07c3d8 +README.md: 94b9b3c8d4bde30cedf56e31d83efe9f5f1dd87c +README.zh.md: 844a2ef030378c32994f7459792db98c779f24b7 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index a301b85d70..94b9b3c8d4 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`): the `Host` header must be a loopback authority or an exact `host[:port]` entry from the plugin's `trustedHosts` config (DNS-rebinding defense), an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Requests without browser markers (curl, tests, native clients) pass — without a browser there is no confused deputy. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment must therefore list the authorities it is reached by in `trustedHosts`; the fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 88d7aa8061..844a2ef030 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`):`Host` 头必须是回环地址权威,或与插件 `trustedHosts` 配置中的某个 `host[:port]` 精确匹配(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不带浏览器标头的请求(curl、测试、原生客户端)直接放行——没有浏览器就不存在"混淆代理人"。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署必须在 `trustedHosts` 中列出自己被访问时使用的权威;这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 37819b9fb9..11dc6d7621 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -3,10 +3,11 @@ * paths a browser opens against a local HTTP API — DNS rebinding (Host names * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page — without blocking non-browser clients - * (no browser markers → no deputy to confuse) or legitimately remote browsers - * (their authority is declared via `trustedHosts`). Network reachability and - * authentication stay out of scope: binding policy belongs to the webserver - * config, and this fence is not an auth layer. + * (no browser markers → no deputy to confuse, and a native client forges Host + * freely anyway) or legitimately remote browsers (their authority is declared + * via `trustedHosts`, or derived by the composing app for IP-literal LAN + * serving). Network reachability and authentication stay out of scope: binding + * policy belongs to the webserver config, and this fence is not an auth layer. */ import type { IncomingHttpHeaders } from 'node:http' @@ -29,42 +30,64 @@ function isLoopbackHostname(hostname: string): boolean { && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } -/** Hostname of a Host-header authority (port stripped, lowercased, IPv6 bracketed), or undefined when unparsable. */ -function authorityHostname(authority: string): string | undefined { +/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ +function parseAuthority(authority: string): URL | undefined { try { // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. - return new URL(`http://${authority}`).hostname + return new URL(`http://${authority}`) } catch { return undefined } } +/** + * Whether the request authority matches a `trustedHosts` entry. An entry with + * an explicit port matches that exact authority; a port-less entry matches the + * hostname on any port (the shape the CLI derives for IP-literal LAN serving, + * where the bound port may be OS-assigned). Both sides compare through WHATWG + * normalization, so case and a redundant `:80` never decide trust. + */ +function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { + return trustedHosts.some((entry) => { + const entryUrl = parseAuthority(entry) + if (entryUrl === undefined) return false + return /:\d+$/.test(entry) + ? entryUrl.host === hostUrl.host + : entryUrl.hostname === hostUrl.hostname + }) +} + /** * Decide whether one /api request may reach the RPC bridge. * @param request - node HTTP request facts (headers). - * @param trustedHosts - exact non-loopback `host[:port]` authorities this deployment serves. - * @returns true when the Host is ours and any browser markers are same-origin. + * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. + * @returns true for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin. */ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { + // Marker gate: Origin and sec-fetch-site exist only when a browser is the + // sender's deputy. Absent both, the sender is the principal itself (curl, + // tests, native shells) and could forge every header below — fencing it + // would add nothing and would break non-browser LAN automation. + const origin = header(request.headers, 'origin') + const secFetchSite = header(request.headers, 'sec-fetch-site') + if (origin === undefined && secFetchSite === undefined) return true // Host fence (DNS-rebinding defense): the browser fills Host from the URL it // believes it is talking to, so a rebound page carries the attacker's domain // here even though the socket lands on this server. const host = header(request.headers, 'host') if (host === undefined) return false - const hostname = authorityHostname(host) - if (hostname === undefined) return false - if (!isLoopbackHostname(hostname) && !trustedHosts.includes(host)) return false + const hostUrl = parseAuthority(host) + if (hostUrl === undefined) return false + if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false // Cross-site fence: modern browsers label the initiator relationship on // every fetch; an explicit cross-site marker is refused regardless of Origin. - if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false + if (secFetchSite === 'cross-site') return false // Origin fence: when a browser attaches an Origin it must be exactly this - // authority. Absent Origin = non-browser client (curl, tests, native shells) - // — allowed, because without a browser there is no confused deputy. The + // authority (compared through the same normalization as the Host). The // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. - const origin = header(request.headers, 'origin') if (origin === undefined) return true try { - return new URL(origin).host === host + return new URL(origin).host === hostUrl.host } catch { return false } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 77f463149e..1bc39ed3ba 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -19,10 +19,11 @@ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { /** - * Exact `host[:port]` authorities this deployment serves beyond loopback. - * The /api trust fence refuses any request whose Host is neither loopback - * nor listed here, so a non-loopback (`0.0.0.0`) deployment must declare - * the names it is reached by. + * Authorities this deployment serves beyond loopback: exact `host:port`, or + * port-less `host` matching any port. The /api trust fence refuses any + * browser request whose Host is neither loopback nor listed here, so a + * non-loopback (`0.0.0.0`) deployment must declare the names it is reached + * by (the dsh CLI derives the machine's LAN IP literals itself). */ trustedHosts?: string[] } diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index eab878e734..5dc2d14b1b 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -8,14 +8,19 @@ function request(headers: Record): { headers: Record } describe('isTrustedApiRequest', () => { - it('accepts loopback Hosts in every spelling, with and without ports', () => { - for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { - expect(isTrustedApiRequest(request({ host }), [])).toBe(true) + it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => { + // No Origin and no sec-fetch-site → the sender is the principal itself + // (it forges Host freely anyway); this is the LAN-serving shape a Host + // fence must not break. + for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) { + expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true) } }) - it('accepts non-browser requests (no Origin, no sec-fetch-site) — curl, tests, native clients', () => { - expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => { + for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) { + expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true) + } }) it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => { @@ -26,13 +31,22 @@ describe('isTrustedApiRequest', () => { }), [])).toBe(false) }) - it('accepts a declared public authority only on exact host[:port] match', () => { + it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => { const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' } expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true) - expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(false) + expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false) expect(isTrustedApiRequest(request(headers), [])).toBe(false) }) + it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => { + expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true) + // An unparsable entry never matches; it must not poison the rest of the list. + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true) + expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false) + }) + it('refuses cross-origin browser markers even on a loopback Host', () => { // Origin present and different → cross-site request that survived preflight rules. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false) @@ -42,19 +56,22 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false) }) - it('accepts a same-origin browser request', () => { + it('accepts a same-origin browser request, with or without an Origin header', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', }), [])).toBe(true) + // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site. + expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) - it('refuses malformed authorities', () => { - expect(isTrustedApiRequest(request({}), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: 'bad host' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '127.0.0.999' }), [])).toBe(false) - expect(isTrustedApiRequest(request({ host: '128.0.0.1' }), [])).toBe(false) + it('refuses malformed or untrusted authorities on browser requests', () => { + const markers = { 'sec-fetch-site': 'same-origin' } + expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false) }) }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index a492a7a9c0..59c8c34ce2 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -80,6 +80,11 @@ describe('connection node half', () => { const loopback = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) expect(loopback.state.status).toBe(404) + // Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl + // shape must reach the bridge even with an empty-by-default trust list. + const lan = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + expect(lan.state.status).toBe(404) // Declared public authority, same-origin browser shape. const declared = fakeResponse() await routes[0]!.handler(fakeRequest({ From 7fd2abd8283cd6ca5eeac37de35fa287af6a4919 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:44:53 +0800 Subject: [PATCH 007/153] feat(host): directory-picker capability seam with dialog and browse backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web GUI's folder picking was hardwired to one interaction: a native OS chooser compiled into the gateway, unusable for remote deployments and swappable only by editing apiproxy source. Directory picking becomes a three-package capability seam in packages/host: ctx.directoryPicker returns a discriminated capability — dialog (the extracted native chooser; host-display only) or browse (new: one-level listing + child creation over Node stdlib, hidden flags host-stamped, symlinks followed, ancestry crumbs; remote-capable). The gateway injects the seam, advertises the kind via host.describe.directoryPicker, serves host.listDirectory / host.createDirectory under browse, and answers directory-picker-unavailable across kinds. cordis.yml is the swap point; apps/cli keeps dialog mounted, so behavior is unchanged until the in-app browser PR flips the default. The connection fixture serves a deterministic browse tree; WorkspacesService gains the browse calls the browser UI will drive. Decision record: .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md --- ...directory-picker-capability-seam.i18n.yaml | 6 + ...-07-28-directory-picker-capability-seam.md | 36 ++++++ ...-28-directory-picker-capability-seam.zh.md | 36 ++++++ apps/cli/cordis.yml | 5 + apps/cli/package.json | 3 +- docs/capability-seams.md | 9 ++ docs/config-catalog.md | 5 +- docs/cordis-catalog/services.md | 14 ++ docs/module-graph.md | 9 ++ packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 69 +++++++++- .../client/connection/src/client/index.ts | 1 + .../connection/tests/connection.spec.ts | 4 +- packages/client/connection/tests/fake-api.ts | 17 ++- packages/client/runtime/src/client/index.ts | 6 +- .../runtime/src/client/workspaces/service.ts | 51 +++++++- packages/client/runtime/tests/fake-api.ts | 17 ++- .../runtime/tests/workspaces-service.spec.ts | 34 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 30 +++++ packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 3 + packages/host/README.zh.md | 3 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 55 +++++++- packages/host/apiproxy/src/api/host.schema.ts | 37 ++++++ packages/host/apiproxy/src/api/host.ts | 62 ++++++++- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 + packages/host/apiproxy/src/api/rpc.schema.ts | 4 + packages/host/apiproxy/src/api/rpc.ts | 4 + packages/host/apiproxy/src/fetch/client.ts | 11 +- packages/host/apiproxy/src/fetch/handler.ts | 7 +- packages/host/apiproxy/src/index.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 98 ++++++++++++-- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 28 +++- packages/host/apiproxy/tsconfig.json | 3 + .../directory-picker-browse/README.i18n.yaml | 6 + .../host/directory-picker-browse/README.md | 21 +++ .../host/directory-picker-browse/README.zh.md | 21 +++ .../host/directory-picker-browse/package.json | 40 ++++++ .../host/directory-picker-browse/src/index.ts | 122 ++++++++++++++++++ .../directory-picker-browse/src/invariant.ts | 25 ++++ .../tests/service.spec.ts | 96 ++++++++++++++ .../directory-picker-browse/tsconfig.json | 24 ++++ .../directory-picker-dialog/README.i18n.yaml | 6 + .../host/directory-picker-dialog/README.md | 17 +++ .../host/directory-picker-dialog/README.zh.md | 17 +++ .../host/directory-picker-dialog/package.json | 40 ++++++ .../host/directory-picker-dialog/src/index.ts | 33 +++++ .../directory-picker-dialog/src/invariant.ts | 25 ++++ .../src/native-picker.ts} | 2 +- .../tests/native-picker.spec.ts} | 2 +- .../tests/service.spec.ts | 21 +++ .../directory-picker-dialog/tsconfig.json | 24 ++++ .../host/directory-picker/README.i18n.yaml | 6 + packages/host/directory-picker/README.md | 19 +++ packages/host/directory-picker/README.zh.md | 19 +++ packages/host/directory-picker/package.json | 37 ++++++ packages/host/directory-picker/src/index.ts | 118 +++++++++++++++++ .../host/directory-picker/src/invariant.ts | 22 ++++ .../host/directory-picker/tests/seam.spec.ts | 35 +++++ packages/host/directory-picker/tsconfig.json | 21 +++ pnpm-lock.yaml | 41 ++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 9 ++ .../verify-package-readme-model-experience.ts | 3 + tsconfig.base.json | 6 + tsconfig.host.json | 3 + 73 files changed, 1536 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md create mode 100644 packages/host/directory-picker-browse/README.i18n.yaml create mode 100644 packages/host/directory-picker-browse/README.md create mode 100644 packages/host/directory-picker-browse/README.zh.md create mode 100644 packages/host/directory-picker-browse/package.json create mode 100644 packages/host/directory-picker-browse/src/index.ts create mode 100644 packages/host/directory-picker-browse/src/invariant.ts create mode 100644 packages/host/directory-picker-browse/tests/service.spec.ts create mode 100644 packages/host/directory-picker-browse/tsconfig.json create mode 100644 packages/host/directory-picker-dialog/README.i18n.yaml create mode 100644 packages/host/directory-picker-dialog/README.md create mode 100644 packages/host/directory-picker-dialog/README.zh.md create mode 100644 packages/host/directory-picker-dialog/package.json create mode 100644 packages/host/directory-picker-dialog/src/index.ts create mode 100644 packages/host/directory-picker-dialog/src/invariant.ts rename packages/host/{apiproxy/src/native-directory-picker.ts => directory-picker-dialog/src/native-picker.ts} (97%) rename packages/host/{apiproxy/tests/native-directory-picker.spec.ts => directory-picker-dialog/tests/native-picker.spec.ts} (99%) create mode 100644 packages/host/directory-picker-dialog/tests/service.spec.ts create mode 100644 packages/host/directory-picker-dialog/tsconfig.json create mode 100644 packages/host/directory-picker/README.i18n.yaml create mode 100644 packages/host/directory-picker/README.md create mode 100644 packages/host/directory-picker/README.zh.md create mode 100644 packages/host/directory-picker/package.json create mode 100644 packages/host/directory-picker/src/index.ts create mode 100644 packages/host/directory-picker/src/invariant.ts create mode 100644 packages/host/directory-picker/tests/seam.spec.ts create mode 100644 packages/host/directory-picker/tsconfig.json 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 new file mode 100644 index 0000000000..ccef0728d9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.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-directory-picker-capability-seam.md +2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213 +2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb 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 new file mode 100644 index 0000000000..8d9d34e7ae --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -0,0 +1,36 @@ +# Agent Note: A capability-discriminated directory-picker seam for the web-GUI host + +Status: implemented + +English | [中文](2026-07-28-directory-picker-capability-seam.zh.md) + +## Problem + +The web GUI's "Open local folder" flow was hardwired to one interaction: `host.pickDirectory` invoked a native OS chooser compiled into `dsh-host-apiproxy` (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma `Harness` 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance. + +## Decision + +A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-dialog`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'dialog', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; the union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. + +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. +- **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. +- **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. +- **The dialog backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide `dialog` natively). The backend names changed from mechanism (`native`/`local` — both run locally) to interaction (`-dialog`/`-browse`). + +## Alternatives considered + +- **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. +- **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 dialog 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. + +## Consequences + +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`. +- The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. +- A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. +- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. 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 new file mode 100644 index 0000000000..282f3905c3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -0,0 +1,36 @@ +# Agent Note:web GUI 宿主的能力可辨识目录选择 seam + +状态:已实现 + +[English](2026-07-28-directory-picker-capability-seam.md) | 中文 + +## 问题 + +web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器(私有模块,仅测试注入缝)。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器(Figma `Harness` 802-56979)需要列举/创建原语,那是**另一种交互契约**,不是同一契约的另一种实现。想换交互只能改网关源码,违背仓库"一切皆插件"的立场。 + +## 决策 + +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-dialog`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'dialog', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 + +并入本决策的位置与策略裁决: + +- **不用 `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 暴露——记为限制,直到原生探测值回其成本。 +- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 +- **dialog 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以原生提供 `dialog`)。后端命名从机制(`native`/`local`——两者都在本机运行)改为交互(`-dialog`/`-browse`)。 + +## 曾考虑的替代方案 + +- **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举契约(hidden 标志、面包屑、home 锚点)不属于存储 seam。 +- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 +- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 +- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 + +## 后果 + +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 +- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 +- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 5397c08746..1ca28ecdaa 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -233,6 +233,11 @@ # 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 backend consumed by the gateway's host.* picker RPCs. +# Swap point: mount '-browse' instead for the in-app browser (remote-capable). +- id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-dialog' + - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: diff --git a/apps/cli/package.json b/apps/cli/package.json index cd3edd2dd1..90b51e0397 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,13 +48,13 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-dialog": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-subagent": "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-timeout-policy": "workspace:^", diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9093f41f15..5cedd97dba 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -136,6 +136,10 @@ flowchart LR svc_spillStore["ctx.spillStore
Spill storage seam"] pkg_spill_local["spill-local"] pkg_spill_policy["spill-policy"] + pkg_directory_picker["directory-picker"] + svc_directoryPicker["ctx.directoryPicker
Workspace-directory picking seam"] + pkg_directory_picker_dialog["directory-picker-dialog"] + pkg_directory_picker_browse["directory-picker-browse"] pkg_webserver["webserver"] svc_httpServer["ctx.httpServer
HTTP route registration"] pkg_connection["connection"] @@ -159,6 +163,9 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_directory_picker --> svc_directoryPicker + pkg_directory_picker_browse --> svc_directoryPicker + pkg_directory_picker_dialog --> svc_directoryPicker pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -235,6 +242,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_directoryPicker --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -348,6 +356,7 @@ flowchart LR | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-dialog`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe. | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d30aff1f02..7d10c920c6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -505,7 +505,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` +Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace` ```ts config-catalog /** Gateway plugin config: host-level agent routing and Workspace creation root. */ @@ -2184,6 +2184,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-browse` ([`packages/host/directory-picker-browse/src/index.ts`](../packages/host/directory-picker-browse/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-dialog` ([`packages/host/directory-picker-dialog/src/index.ts`](../packages/host/directory-picker-dialog/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) @@ -2230,6 +2232,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 75464938b0..eb212207b3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -469,6 +469,20 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam) + +Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls. + +```ts cordis-catalog +/** + * The backend's interaction capability. + * @returns the discriminated capability consumers switch on. + */ +abstract capability(): DirectoryPickerCapability +``` + +Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. diff --git a/docs/module-graph.md b/docs/module-graph.md index f69b89e379..03f4bee072 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -182,6 +182,9 @@ flowchart TD end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] + pkg_host_directory_picker["host-directory-picker"] + pkg_host_directory_picker_browse["host-directory-picker-browse"] + pkg_host_directory_picker_dialog["host-directory-picker-dialog"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -257,6 +260,9 @@ flowchart TD pkg_code_runtime --> pkg_invariants pkg_jsonrpc_demo --> pkg_invariants pkg_host_apiproxy --> pkg_invariants + pkg_host_directory_picker --> pkg_invariants + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_dialog --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants @@ -924,6 +930,9 @@ flowchart TD | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) | | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-dialog`](../packages/host/directory-picker-dialog) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a1380c4b58..b68a7c1565 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,6 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, DirectoryPickerKind, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5c7befd8c9..a490f0d27d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -419,6 +419,37 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { updatedAt: fixtureEpoch, }] let nextWorkspace = 1 + + // In-memory browse tree behind the fixture's `browse` picker capability — + // deterministic content mirroring the design mock so assembled Web tests + // and snapshots can walk it. Leaves are materialized lazily: a child listed + // by its parent lists as empty until something is created inside it. + const FIXTURE_HOME = '/home/fixture' + const directoryTree = new Map([ + ['/', ['home']], + ['/home', ['fixture']], + [FIXTURE_HOME, ['Documents', 'Downloads', '.config']], + [`${FIXTURE_HOME}/Documents`, [ + 'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform', + 'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog', + ]], + ]) + const childrenOf = (path: string): string[] | undefined => { + const known = directoryTree.get(path) + if (known !== undefined) return known + const parent = path.slice(0, path.lastIndexOf('/')) || '/' + const name = path.slice(path.lastIndexOf('/') + 1) + return directoryTree.get(parent)?.includes(name) === true ? [] : undefined + } + const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => { + const crumbs = [{ name: '/', path: '/', hidden: false }] + let acc = '' + for (const segment of path.split('/').filter(Boolean)) { + acc += `/${segment}` + crumbs.push({ name: segment, path: acc, hidden: false }) + } + return crumbs + } const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) /** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */ const pendingApprovalRpcId = mint() @@ -774,8 +805,40 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, host: { - describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), - pickDirectory: request => ok(request, { path: null }), + describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, directoryPicker: 'browse' as const }), + pickDirectory: request => err(request, { + code: 'directory-picker-unavailable', + message: 'the fixture host serves the browse capability', + details: { capability: 'browse' }, + }), + listDirectory: (request) => { + const target = request.payload.path ?? FIXTURE_HOME + const children = childrenOf(target) + if (children === undefined) { + return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } }) + } + return ok(request, { + path: target, + home: FIXTURE_HOME, + crumbs: crumbsOf(target), + entries: [...children].sort((a, b) => a.localeCompare(b)) + .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + }) + }, + createDirectory: (request) => { + const parent = request.payload.path + const children = childrenOf(parent) + if (children === undefined) { + return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) + } + const target = `${parent}/${request.payload.name}` + if (children.includes(request.payload.name)) { + return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) + } + directoryTree.set(parent, [...children, request.payload.name]) + directoryTree.set(target, []) + return ok(request, { path: target }) + }, }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -1027,6 +1090,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) + case 'host.listDirectory': return this.api.host.listDirectory(request) + case 'host.createDirectory': return this.api.host.createDirectory(request) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0e50d8617f..8e29b0b02e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts' export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + DirectoryEntry, DirectoryListing, DirectoryPickerKind, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..bce6fce2ce 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -75,7 +75,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const })) await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() @@ -199,7 +199,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1bb49b19fb..06e62cf7a4 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -62,11 +62,22 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onDescribe: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -88,6 +99,8 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index ec39ce9e1c..8e693c3514 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' +export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -21,7 +21,9 @@ export type { export type { SessionListPhase } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' -export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' +export type { + DirectoryEntry, DirectoryListing, DirectoryPickerKind, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-connection/client' // Runtime owns the snapshot store; web-react only binds it to React. export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts' export type { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index fe345801c6..14ecace381 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,8 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, + DirectoryListing, DirectoryPickerKind, IApiClient, RpcError, + SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -29,6 +30,14 @@ export class WorkspaceCreateError extends Error { } } +/** Structured browse failure so the directory browser can branch on Host business codes. */ +export class DirectoryBrowseError extends Error { + constructor(readonly rpcError: RpcError) { + super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'DirectoryBrowseError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -171,7 +180,7 @@ export class WorkspacesService { } /** - * Open the Host's native directory picker. + * Open the Host's native directory picker (the `dialog` capability). * @returns the selected path, or null when the user cancelled. */ async pickDirectory(): Promise { @@ -182,6 +191,44 @@ export class WorkspacesService { return response.result.value.path } + /** + * The directory-picking interaction the Host composed — the fact the picker + * UI branches on (`dialog` opens the native chooser; `browse` opens the + * in-app browser). Read per flow open: one describe round trip, no cache to + * go stale across reconnects. + * @returns the Host's advertised picker kind. + */ + async directoryPickerKind(): Promise { + const response = await this.api.host.describe({}) + if (!response.result.ok) { + throw new Error(`host describe failed: ${response.result.error.message}`) + } + return response.result.value.directoryPicker + } + + /** + * List one directory level through the Host's `browse` capability. + * @param path - absolute directory to list; absent lists the Host home directory. + * @returns the level's listing with breadcrumb ancestry. + */ + async listDirectory(path?: string): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value + } + + /** + * Create one child directory through the Host's `browse` capability. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment. + * @returns the created directory's absolute path. + */ + async createDirectory(path: string, name: string): Promise { + const response = await this.api.host.createDirectory({ path, name }) + if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) + return response.result.value.path + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index a5eecd0cf5..98a222d9ae 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -80,11 +80,22 @@ export class FakeApiClient implements IApiClient { payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onDescribe: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -106,6 +117,8 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 5327066651..7fda11f54f 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' +import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -234,6 +234,38 @@ describe('WorkspacesService', () => { api.onPickDirectory = () => Promise.resolve(ok({ path: null })) await expect(workspaces.pickDirectory()).resolves.toBeNull() expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} })) + await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/) + }) + + it('reads the picker kind from describe per call, failing loud on an unreachable host', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + await expect(workspaces.directoryPickerKind()).resolves.toBe('browse') + api.onDescribe = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await expect(workspaces.directoryPickerKind()).rejects.toThrow(/host describe failed/) + }) + + it('passes listings and creation through the browse wire, wrapping business failures', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api)) + const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }] } + api.onListDirectory = () => Promise.resolve(ok(listing)) + await expect(workspaces.listDirectory()).resolves.toEqual(listing) + await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing) + // The optional path is omitted from the payload, not sent as undefined. + expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }]) + api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } })) + const listFailure = workspaces.listDirectory('/x') + await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError) + await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } }) + + await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new') + expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }]) + api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } })) + await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } }) }) it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 084652ea26..04b2e60ef0 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -260,6 +260,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'directoryPicker', + summary: 'Abstract directory-picking service.', + methods: [ + { + signature: 'abstract capability(): DirectoryPickerCapability', + jsDoc: '/**\n * The backend\'s interaction capability.\n * @returns the discriminated capability consumers switch on.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1581,6 +1591,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DiffResultView', declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}', }, + { + name: 'DirectoryEntry', + declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}', + }, + { + name: 'DirectoryListing', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n}', + }, + { + name: 'DirectoryPickerBrowseCapability', + declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', + }, + { + name: 'DirectoryPickerCapability', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;', + }, + { + name: 'DirectoryPickerDialogCapability', + declaration: 'export interface DirectoryPickerDialogCapability {\n kind: \'dialog\';\n pick(signal: AbortSignal): Promise;\n}', + }, { name: 'Domain', declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}', diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index b406dd394e..9421dce1c5 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: 61e50cb64b95932085342b01a22d029cf8d5a228 -README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc +README.md: 81c483674c0d30847318b8fd9014bd8bb7d341c2 +README.zh.md: 8b5ecd89ff4b1407cfa8c2bad63c77412b5fd16c diff --git a/packages/host/README.md b/packages/host/README.md index 61e50cb64b..81c483674c 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -8,5 +8,8 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and |---|---|---| | `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` | | `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | +| `directory-picker/` | Workspace-directory picking seam: discriminated `dialog`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | +| `directory-picker-dialog/` | Native-OS-chooser backend (osascript / PowerShell / Zenity+KDialog); host-display only | (registers `ctx.directoryPicker`) | +| `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) | `apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 3109eccf89..8b5ecd89ff 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -8,5 +8,8 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承 |---|---|---| | `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` | | `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | +| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `dialog`/`browse` 能力 | `ctx.directoryPicker` | +| `directory-picker-dialog/` | 原生 OS 选择器后端(osascript/PowerShell/Zenity+KDialog);仅宿主屏幕可用 | (注册 `ctx.directoryPicker`) | +| `directory-picker-browse/` | 应用内浏览后端:基于 Node 标准库的列举/创建原语;支持远程 | (注册 `ctx.directoryPicker`) | `apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bfda016b16..55fe86c794 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: 2f51aa23e2639e2e98dfdd8aaf71e807d641c3dc -README.zh.md: 687e60879702a762d9e85295789b77daea4bd4ac +README.md: 7c53e6dc9ac5758fce91d8b39abbfab6641384d1 +README.zh.md: 5089935fe545bfc6ac3ddb39b9a2a25b50e1ceab diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 2f51aa23e2..7c53e6dc9a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,7 +16,7 @@ Session model routing is a session-domain contract. `session.models` returns the Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers this method like every other `/api` request. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `dialog`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. @@ -39,4 +39,4 @@ None; this package neither assembles nor sends a provider request. - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. -- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser. +- **Linux native picker requires desktop tooling** — under the `dialog` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [dialog backend README](../directory-picker-dialog/README.md)). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 687e608797..5089935fe5 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,7 +16,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖该方法。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind,调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `dialog` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 @@ -39,4 +39,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 -- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。 +- **Linux 原生选择器依赖桌面工具**:在 `dialog` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [dialog 后端 README](../directory-picker-dialog/README.md))。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0c7107a1f9..676f3ac319 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 58922284be..6e302e3e57 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -39,7 +39,7 @@ import type { AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction' -import { pickNativeDirectory } from './native-directory-picker.ts' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 @@ -193,6 +193,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade } } +/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */ +function directoryError(error: unknown): RpcError { + if (error instanceof DirectoryPickerError) { + return { code: error.code, message: error.message, details: { path: error.path } } + } + return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } +} + /** Resolved Host routing and project-directory defaults consumed by the API implementation. */ export interface ApiProxyDefaults { provider: string @@ -201,8 +209,6 @@ export interface ApiProxyDefaults { cwd: string /** Parent directory for name-created workspaces. */ workspaceRoot: string - /** Native single-directory picker; injectable for carrier tests. */ - pickDirectory?: (signal: AbortSignal) => Promise } /** The tool/call payload fields the presenter path reads. */ @@ -990,12 +996,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro provider: defaults.provider, model: defaults.model, attachedSessions: ctx.agents.list().length, + directoryPicker: ctx.directoryPicker.capability().kind, })) }, async pickDirectory(request, signal) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'dialog') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.pickDirectory needs the dialog capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } try { - const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal) + const path = await capability.pick(signal) return ok(request, { path }) } catch (error: unknown) { if (signal.aborted) { @@ -1012,6 +1027,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } }, + + async listDirectory(request) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'browse') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } + try { + return ok(request, await capability.list(request.payload.path)) + } catch (error: unknown) { + return err(request, directoryError(error)) + } + }, + + async createDirectory(request) { + const capability = ctx.directoryPicker.capability() + if (capability.kind !== 'browse') { + return err(request, { + code: 'directory-picker-unavailable', + message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, + details: { capability: capability.kind }, + }) + } + try { + return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) }) + } catch (error: unknown) { + return err(request, directoryError(error)) + } + }, }, commands: { diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index c7353c4360..a73b659970 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -3,6 +3,7 @@ */ import { z } from 'zod' +import type { DirectoryEntry } from './host.ts' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' @@ -16,6 +17,7 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), + directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ @@ -25,3 +27,38 @@ export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>> + +/** Directory row shared by listing entries and breadcrumb crumbs. */ +export const directoryEntrySchema = z.object({ + name: z.string(), + path: z.string(), + hidden: z.boolean(), +}) satisfies z.ZodType> + +/** host.listDirectory request payload; an absent path lists the home directory. */ +export const hostListDirectoryRequestSchema = z.object({ + path: z.string().optional(), +}) satisfies z.ZodType>> + +/** host.listDirectory response value. */ +export const hostListDirectoryValueSchema = z.object({ + path: z.string(), + home: z.string(), + crumbs: z.array(directoryEntrySchema), + entries: z.array(directoryEntrySchema), +}) satisfies z.ZodType>> + +/** host.createDirectory request payload: name must be one plain path segment. */ +export const hostCreateDirectoryRequestSchema = z.object({ + path: z.string(), + name: z.string(), +}).refine( + payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..' + && !/[/\\]/.test(payload.name), + { message: 'host.createDirectory requires a single non-blank path segment name' }, +) satisfies z.ZodType>> + +/** host.createDirectory response value: the created directory's absolute path. */ +export const hostCreateDirectoryValueSchema = z.object({ + path: z.string(), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index e3573346f4..abdb3c468b 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,6 +5,40 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' +/** + * The composed directory-picker interaction the host serves (mirror of the + * `ctx.directoryPicker` capability kind): `dialog` = one native OS chooser on + * the host display (`host.pickDirectory`); `browse` = in-app listing/creation + * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method + * outside the advertised kind fails with `directory-picker-unavailable`. + */ +export type DirectoryPickerKind = 'dialog' | 'browse' + +/** One directory row of a listing: a child entry or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — the client never joins path segments itself. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** host.listDirectory response value: one directory level plus its ancestry. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] +} + /** Host-level unary methods. */ export interface HostApi { /** @@ -13,7 +47,8 @@ export interface HostApi { * directory (root for session persistence and tool execution); provider/model = the defaults * applied when a new agent doesn't specify them explicitly, absent when the host configures * no explicit default (the adapter falls back internally); - * attachedSessions = count of currently attached sessions (those with a live agent). + * attachedSessions = count of currently attached sessions (those with a live agent); + * directoryPicker = the composed picker interaction the client renders for. */ describe(request: RpcRequest<{}>): Promise> - /** Open the operating system's single-directory picker; cancellation returns null. */ + /** + * Open the operating system's single-directory picker; cancellation returns + * null. Only served under the `dialog` capability. + */ pickDirectory( request: RpcRequest<{}>, signal: AbortSignal, ): Promise> + + /** + * List one directory level for the in-app browser; an absent path lists the + * host account's home directory. Only served under the `browse` capability; + * unreadable or missing targets fail with `directory-unreadable`. + */ + listDirectory( + request: RpcRequest<{ path?: string }>, + ): Promise> + + /** + * Create one child directory under an existing parent (the browser's + * "New folder"). Only served under the `browse` capability; an existing + * child fails with `directory-exists`, every other filesystem failure with + * `directory-create-failed`. + */ + createDirectory( + request: RpcRequest<{ path: string; name: string }>, + ): Promise> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index ad5fbd3bf0..84cd8f8252 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,7 +29,7 @@ export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary, } from './sessions.ts' -export type { HostApi } from './host.ts' +export type { DirectoryEntry, DirectoryListing, DirectoryPickerKind, HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index d98a7c01b3..668ecfc5c7 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -26,6 +26,8 @@ export interface RpcMethodMap { 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] + 'host.listDirectory': HostApi['listDirectory'] + 'host.createDirectory': HostApi['createDirectory'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] 'workspace.rename': WorkspaceApi['rename'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 300cc210b6..b3632798fb 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -42,6 +42,10 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), + z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), + z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 52b2f503dd..c1eb4c06f0 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -39,6 +39,10 @@ export interface RpcErrorDetailsMap { 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } + 'directory-unreadable': { path: string } + 'directory-exists': { path: string } + 'directory-create-failed': { path: string } + 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 62bef32b29..912d98c477 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -13,7 +13,10 @@ import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts' import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts' -import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts' +import { + hostCreateDirectoryValueSchema, hostDescribeValueSchema, + hostListDirectoryValueSchema, hostPickDirectoryValueSchema, +} from '../api/host.schema.ts' import { sessionCancelValueSchema, sessionCreateValueSchema, @@ -61,6 +64,8 @@ export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>> + listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>> + createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>> } workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>> @@ -98,6 +103,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.pickDirectory', payload, signal, false), + listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal), + createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 8160f9d69a..29d57b3b55 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -23,7 +23,10 @@ import { sessionPromptRequestSchema, sessionSelectModelRequestSchema, } from '../api/sessions.schema.ts' -import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts' +import { + hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, + hostListDirectoryRequestSchema, hostPickDirectoryRequestSchema, +} from '../api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceDeleteRequestSchema, @@ -60,6 +63,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, + 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) }, + 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e7ef6c7332..44944e539a 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -45,7 +45,7 @@ export interface Config { * project directory and the fallback parent for name-created Workspaces. */ export class ApiProxyService extends Service implements ApiProxy { - static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] + static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] static Config: z = z.object({ provider: z.string().required(), diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index c6354db928..9572c76b23 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -10,6 +10,8 @@ import type { Session } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' @@ -57,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), - pickDirectory?: (signal: AbortSignal) => Promise, + picker: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null }, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -92,36 +94,114 @@ async function harness( }, } ctx.agents.setFactory(factory) + // Structural picker fake: the gateway only reads capability(); a stable + // object per harness mirrors the seam's stability contract. + ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd: workspaceRoot, workspaceRoot, - ...pickDirectory === undefined ? {} : { pickDirectory }, }) return { api, ctx, storageDomain, workspaceRoot } } describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the injected native boundary', async () => { - const selected = await harness(undefined, async () => '/tmp/project') + it('returns a selected path or explicit cancellation from the dialog capability', async () => { + const selected = await harness(undefined, { kind: 'dialog', pick: async () => '/tmp/project' }) expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: '/tmp/project' } }) - const cancelled = await harness(undefined, async () => null) + const cancelled = await harness(undefined, { kind: 'dialog', pick: async () => null }) expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: null } }) }) - it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - })) + it('propagates abort into the dialog capability as a cancelled RPC error', async () => { + const { api } = await harness(undefined, { + kind: 'dialog', + pick: signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }), + }) const abort = new AbortController() const pending = api.host.pickDirectory(request({}), abort.signal) abort.abort() expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) + + it('folds a non-abort dialog failure into an internal error', async () => { + const { api } = await harness(undefined, { kind: 'dialog', pick: async () => { throw new Error('no chooser installed') } }) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) + + it('refuses the dialog RPC under a browse composition', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + const response = await api.host.pickDirectory(request({}), new AbortController().signal) + expect(response.result).toMatchObject({ + ok: false, + error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } }, + }) + }) +}) + +/** Canned browse capability: one listing, one created path, typed failures on demand. */ +const BROWSE_STUB: DirectoryPickerCapability = { + kind: 'browse', + list: async (path) => { + if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied') + const target = path ?? '/home/user' + return { + path: target, + home: '/home/user', + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + } + }, + createDirectory: async (path, name) => { + if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists') + if (name === 'unwritable') throw new Error('disk detached') + return `${path}/${name}` + }, +} + +describe('host.listDirectory / host.createDirectory', () => { + it('serves listings and creation through the browse capability, defaulting to home', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + const home = await api.host.listDirectory(request({})) + expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) + const listed = await api.host.listDirectory(request({ path: '/home/user/projects' })) + expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) + const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) + expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) + }) + + it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { + const { api } = await harness(undefined, BROWSE_STUB) + expect((await api.host.listDirectory(request({ path: '/denied' }))).result).toMatchObject({ + ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ + ok: false, error: { code: 'directory-exists' }, + }) + expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({ + ok: false, error: { code: 'internal' }, + }) + }) + + it('refuses the browse RPCs under a dialog composition and advertises the kind in describe', async () => { + const { api } = await harness() + expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'dialog' } }) + expect((await api.host.listDirectory(request({}))).result).toMatchObject({ + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } }, + }) + expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } }, + }) + const browse = await harness(undefined, BROWSE_STUB) + expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } }) + }) }) describe('workspace.create', () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 0885b7b896..dc92128487 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -48,8 +48,10 @@ function scriptedApi(overrides: { ...overrides.sessions, }, host: { - describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), + describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, directoryPicker: 'browse' as const }), pickDirectory: r => ok(r, { path: null }), + listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }), + createDirectory: r => ok(r, { path: '/t/new' }), ...overrides.host, }, workspace: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 318dbc970e..a0645f4750 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -75,11 +75,17 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } + return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'dialog' as const } } } }, async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } }, + async listDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } } } + }, + async createDirectory(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } + }, }, workspace: { async list(request) { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 9f1309b476..f64883c85b 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -12,7 +12,11 @@ import { sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, } from '../src/api/sessions.schema.ts' -import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' +import { + hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, + hostDescribeRequestSchema, hostDescribeValueSchema, + hostListDirectoryRequestSchema, hostListDirectoryValueSchema, +} from '../src/api/host.schema.ts' import { workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, @@ -204,9 +208,27 @@ describe('sessions domain schemas', () => { describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 }) + const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' }) expect(value.attachedSessions).toBe(2) - expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() + expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow() + }) + + it('validates the browse listing/creation payloads', () => { + expect(hostListDirectoryRequestSchema.parse({})).toEqual({}) + expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' }) + const listing = hostListDirectoryValueSchema.parse({ + path: '/home/u/p', + home: '/home/u', + crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }], + entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }], + }) + expect(listing.entries[0]?.hidden).toBe(true) + expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' }) + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow() + } + expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' }) }) }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index f5aabb1cf8..100cbe2893 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../workspace/workspace" }, + { + "path": "../directory-picker" + }, { "path": "../../support/invariants" } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml new file mode 100644 index 0000000000..fea6c71d41 --- /dev/null +++ b/packages/host/directory-picker-browse/README.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 packages/host/directory-picker-browse/README.md +README.md: f86f74acf4922d490b23c033a281436f1a428f13 +README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md new file mode 100644 index 0000000000..f86f74acf4 --- /dev/null +++ b/packages/host/directory-picker-browse/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-host-directory-picker-browse + +English | [中文](README.zh.md) + +The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. + +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. 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). + +## Model Experience + +None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. +- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. +- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md new file mode 100644 index 0000000000..0d240630a8 --- /dev/null +++ b/packages/host/directory-picker-browse/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-host-directory-picker-browse + +[English](README.md) | 中文 + +[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 + +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 + +## 模型体验 + +无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 +- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 +- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json new file mode 100644 index 0000000000..c1e8626bed --- /dev/null +++ b/packages/host/directory-picker-browse/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker-browse", + "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", + "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", + "dependencies": { + "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts new file mode 100644 index 0000000000..5f2a9429c0 --- /dev/null +++ b/packages/host/directory-picker-browse/src/index.ts @@ -0,0 +1,122 @@ +/** + * Browse backend of the directory-picker seam: registers `ctx.directoryPicker` + * with the `browse` capability — one-level directory listing and child-directory + * creation over the host filesystem via Node's stdlib (which already carries + * the per-OS adaptation). Nothing renders on the host display, so this backend + * serves remote clients the dialog backend cannot. Policy decisions (hidden + * entries flagged but returned, symlinks followed, whole-filesystem scope) are + * recorded in the directory-picker seam Agent Note. + * @module @deepseek-ai/dsh-host-directory-picker-browse + */ + +import { mkdir, readdir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { + DirectoryPicker, DirectoryPickerError, +} from '@deepseek-ai/dsh-host-directory-picker' +import type { + DirectoryEntry, DirectoryListing, DirectoryPickerCapability, +} from '@deepseek-ai/dsh-host-directory-picker' + +/** + * Ancestor chain from the filesystem root to `target` inclusive — the + * breadcrumb rows of a listing, every one a jump target. + */ +function ancestryCrumbs(target: string): DirectoryEntry[] { + const crumbs: DirectoryEntry[] = [] + let current = target + for (;;) { + const parent = dirname(current) + // basename of a root is '' — label the root crumb by its full path ('/', 'C:\'). + crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false }) + if (parent === current) return crumbs + current = parent + } +} + +/** Message text of an unknown thrown value. */ +function messageOf(error: unknown): string { + /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ + return error instanceof Error ? error.message : String(error) +} + +/** + * One listing row for a dirent, following symlinks to directories; null for + * non-directories and broken/cyclic links (skipped silently — the browser + * shows what can be entered, and a broken link cannot). + */ +async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise { + const path = join(parent, name) + let enterable = isDirectory + if (!enterable && isSymbolicLink) { + try { + enterable = (await stat(path)).isDirectory() + } catch { + // Broken or cyclic symlink: stat is the probe, failure means "not enterable". + return null + } + } + if (!enterable) return null + // POSIX hidden convention; Windows' hidden attribute is not exposed by + // dirents (Known Limitations). The client owns whether hidden rows show. + return { name, path, hidden: name.startsWith('.') } +} + +/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */ +export default class BrowseDirectoryPicker extends DirectoryPicker { + private readonly browseCapability: DirectoryPickerCapability = { + kind: 'browse', + list: path => this.list(path), + createDirectory: (path, name) => this.createDirectory(path, name), + } + + /** + * The browse interaction capability. + * @returns the stable `browse` capability object. + */ + capability(): DirectoryPickerCapability { + return this.browseCapability + } + + private async list(path?: string): Promise { + const home = homedir() + const target = resolve(path ?? home) + let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] + try { + const dirents = await readdir(target, { withFileTypes: true }) + names = dirents.map(dirent => ({ + name: dirent.name, + isDirectory: dirent.isDirectory(), + isSymbolicLink: dirent.isSymbolicLink(), + })) + } catch (error: unknown) { + throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) + } + const rows = await Promise.all(names.map(entry => directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink))) + const entries = rows.filter((row): row is DirectoryEntry => row !== null) + .sort((a, b) => a.name.localeCompare(b.name)) + return { path: target, home, crumbs: ancestryCrumbs(target), entries } + } + + private async createDirectory(path: string, name: string): Promise { + const parent = resolve(path) + // The backend owns segment validation (the wire schema also refuses these, + // but direct service consumers must hit the same fence). + if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { + throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`) + } + const target = join(parent, name) + try { + // Non-recursive: the parent is the directory the browser is showing, so + // a missing parent is a real failure, not a level to invent. + await mkdir(target) + return target + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') { + throw new DirectoryPickerError('directory-exists', target, `${target} already exists`) + } + throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`) + } + } +} diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts new file mode 100644 index 0000000000..ba4bfe7b13 --- /dev/null +++ b/packages/host/directory-picker-browse/src/invariant.ts @@ -0,0 +1,25 @@ +/** + * Package-owned invariant companion for the browse directory-picker backend. + * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-browse-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */ +const install: InvariantInstaller = () => {} + +/** + * Register the browse directory-picker 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)) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts new file mode 100644 index 0000000000..3833395c11 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -0,0 +1,96 @@ +/** Behavior of the browse backend over a real temporary directory tree. */ + +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' +import BrowseDirectoryPicker from '../src/index.ts' + +let root: string +let capability: DirectoryPickerBrowseCapability +let dispose: () => Promise + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-browse-')) + await mkdir(join(root, 'projects')) + await mkdir(join(root, 'projects', 'harness')) + await mkdir(join(root, '.hidden-dir')) + await writeFile(join(root, 'notes.txt'), 'not a directory') + await symlink(join(root, 'projects'), join(root, 'linked'), 'junction') + await symlink(join(root, 'gone'), join(root, 'broken'), 'junction') + + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker) + await fiber.await() + const picked = ctx.get('directoryPicker')!.capability() + if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + capability = picked + dispose = () => fiber.dispose() +}) + +afterAll(async () => { + await dispose() + await rm(root, { recursive: true, force: true }) +}) + +describe('BrowseDirectoryPicker', () => { + it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { + const listing = await capability.list(root) + expect(listing.path).toBe(root) + expect(listing.home).toBe(homedir()) + expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) + expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) + // Every entry path is absolute and host-joined — clients never join segments. + expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true) + }) + + it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { + const listing = await capability.list(join(root, 'projects')) + const tail = listing.crumbs.at(-1)! + expect(tail).toMatchObject({ name: 'projects', path: join(root, 'projects'), hidden: false }) + expect(listing.crumbs.at(-2)!.path).toBe(root) + expect(listing.crumbs.at(-2)!.name).toBe(basename(root)) + // The chain starts at the filesystem root, whose crumb is labeled by its full path. + expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path) + }) + + it('lists the home directory when no path is given', async () => { + const listing = await capability.list() + expect(listing.path).toBe(homedir()) + }) + + it('throws directory-unreadable for a missing target', async () => { + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + expect((failure as DirectoryPickerError).path).toBe(missing) + }) + + it('creates one child directory and surfaces it in the next listing', async () => { + const created = await capability.createDirectory(root, 'fresh') + expect(created).toBe(join(root, 'fresh')) + const listing = await capability.list(root) + expect(listing.entries.map(entry => entry.name)).toContain('fresh') + }) + + it('refuses an existing child with directory-exists', async () => { + const failure = await capability.createDirectory(root, 'projects').catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-exists') + }) + + it('refuses non-segment names and other filesystem failures with directory-create-failed', async () => { + for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) { + const failure = await capability.createDirectory(root, name).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-create-failed') + } + // Missing parent is a real failure, not a level to invent. + const missingParent = await capability.createDirectory(join(root, 'no-such-dir'), 'child').catch((error: unknown) => error) + expect((missingParent as DirectoryPickerError).code).toBe('directory-create-failed') + }) +}) diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json new file mode 100644 index 0000000000..99ca673189 --- /dev/null +++ b/packages/host/directory-picker-browse/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../directory-picker" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/directory-picker-dialog/README.i18n.yaml b/packages/host/directory-picker-dialog/README.i18n.yaml new file mode 100644 index 0000000000..3cd3d36702 --- /dev/null +++ b/packages/host/directory-picker-dialog/README.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 packages/host/directory-picker-dialog/README.md +README.md: fe07303557da6b27bb89eb761efba5555c4308c0 +README.zh.md: 214259264d5385ddad1ea6425149c53e31de55d0 diff --git a/packages/host/directory-picker-dialog/README.md b/packages/host/directory-picker-dialog/README.md new file mode 100644 index 0000000000..fe07303557 --- /dev/null +++ b/packages/host/directory-picker-dialog/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-host-directory-picker-dialog + +English | [中文](README.zh.md) + +The **native-OS-dialog backend** of the [directory-picker seam](../directory-picker/README.md): `DialogDirectoryPicker` registers `ctx.directoryPicker` with the `dialog` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. + +## Model Experience + +None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). diff --git a/packages/host/directory-picker-dialog/README.zh.md b/packages/host/directory-picker-dialog/README.zh.md new file mode 100644 index 0000000000..214259264d --- /dev/null +++ b/packages/host/directory-picker-dialog/README.zh.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-host-directory-picker-dialog + +[English](README.md) | 中文 + +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 对话框后端**:`DialogDirectoryPicker` 以 `dialog` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。 + +## 模型体验 + +无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 diff --git a/packages/host/directory-picker-dialog/package.json b/packages/host/directory-picker-dialog/package.json new file mode 100644 index 0000000000..94b22e6feb --- /dev/null +++ b/packages/host/directory-picker-dialog/package.json @@ -0,0 +1,40 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker-dialog", + "description": "Native-OS-dialog backend of the directory-picker seam for the DeepSeek Harness web GUI host", + "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", + "dependencies": { + "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/directory-picker-dialog/src/index.ts b/packages/host/directory-picker-dialog/src/index.ts new file mode 100644 index 0000000000..bc9814a5e7 --- /dev/null +++ b/packages/host/directory-picker-dialog/src/index.ts @@ -0,0 +1,33 @@ +/** + * Dialog backend of the directory-picker seam: registers `ctx.directoryPicker` + * with the `dialog` capability, opening one native OS chooser on the host + * display per pick (macOS `osascript`, Windows STA PowerShell + * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable + * when the operator sits at the host's screen; remote deployments compose the + * browse backend instead. + * @module @deepseek-ai/dsh-host-directory-picker-dialog + */ + +import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' +import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker' +import { pickNativeDirectory } from './native-picker.ts' + +export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts' +export { pickNativeDirectory } from './native-picker.ts' + +/** The `ctx.directoryPicker` dialog implementation (stable capability object per service life). */ +export default class DialogDirectoryPicker extends DirectoryPicker { + private readonly dialogCapability: DirectoryPickerCapability = { + kind: 'dialog', + /* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */ + pick: signal => pickNativeDirectory(signal), + } + + /** + * The dialog interaction capability. + * @returns the stable `dialog` capability object. + */ + capability(): DirectoryPickerCapability { + return this.dialogCapability + } +} diff --git a/packages/host/directory-picker-dialog/src/invariant.ts b/packages/host/directory-picker-dialog/src/invariant.ts new file mode 100644 index 0000000000..cbd3e63517 --- /dev/null +++ b/packages/host/directory-picker-dialog/src/invariant.ts @@ -0,0 +1,25 @@ +/** + * Package-owned invariant companion for the dialog directory-picker backend. + * @module @deepseek-ai/dsh-host-directory-picker-dialog/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-dialog' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-dialog-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: each pick is one stateless subprocess round trip; the dialog outcome is only the returned path. */ +const install: InvariantInstaller = () => {} + +/** + * Register the dialog directory-picker 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)) diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/directory-picker-dialog/src/native-picker.ts similarity index 97% rename from packages/host/apiproxy/src/native-directory-picker.ts rename to packages/host/directory-picker-dialog/src/native-picker.ts index 8bc0bc99ce..a9602dc020 100644 --- a/packages/host/apiproxy/src/native-directory-picker.ts +++ b/packages/host/directory-picker-dialog/src/native-picker.ts @@ -1,4 +1,4 @@ -/** Cross-platform native single-directory picker used by the local GUI carrier. */ +/** Cross-platform native single-directory chooser behind the dialog backend's capability. */ import { execFile } from 'node:child_process' diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/directory-picker-dialog/tests/native-picker.spec.ts similarity index 99% rename from packages/host/apiproxy/tests/native-directory-picker.spec.ts rename to packages/host/directory-picker-dialog/tests/native-picker.spec.ts index 783a67a04b..87e25ff877 100644 --- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts +++ b/packages/host/directory-picker-dialog/tests/native-picker.spec.ts @@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { describe, expect, it, vi } from 'vitest' -import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts' +import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-picker.ts' function failure(code: string | number, stderr = ''): Error { return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr }) diff --git a/packages/host/directory-picker-dialog/tests/service.spec.ts b/packages/host/directory-picker-dialog/tests/service.spec.ts new file mode 100644 index 0000000000..f56f93ec41 --- /dev/null +++ b/packages/host/directory-picker-dialog/tests/service.spec.ts @@ -0,0 +1,21 @@ +/** Registration/capability behavior of the dialog backend (the seam's cordis half). */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import DialogDirectoryPicker from '../src/index.ts' + +describe('DialogDirectoryPicker', () => { + it('registers ctx.directoryPicker with a stable dialog capability and leaves with its fiber', async () => { + const ctx = new Context() + const fiber = ctx.plugin(DialogDirectoryPicker) + await fiber.await() + const picker = ctx.get('directoryPicker') + expect(picker).toBeInstanceOf(DialogDirectoryPicker) + const capability = picker!.capability() + expect(capability.kind).toBe('dialog') + // Stability: consumers may capture the capability object across calls. + expect(picker!.capability()).toBe(capability) + await fiber.dispose() + expect(ctx.get('directoryPicker')).toBeUndefined() + }) +}) diff --git a/packages/host/directory-picker-dialog/tsconfig.json b/packages/host/directory-picker-dialog/tsconfig.json new file mode 100644 index 0000000000..99ca673189 --- /dev/null +++ b/packages/host/directory-picker-dialog/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../directory-picker" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml new file mode 100644 index 0000000000..0d5c753e92 --- /dev/null +++ b/packages/host/directory-picker/README.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 packages/host/directory-picker/README.md +README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868 +README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md new file mode 100644 index 0000000000..c1a801cf72 --- /dev/null +++ b/packages/host/directory-picker/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker + +English | [中文](README.zh.md) + +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. + +Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). + +## Model Experience + +None, as the seam serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note. diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md new file mode 100644 index 0000000000..c352b35b70 --- /dev/null +++ b/packages/host/directory-picker/README.zh.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-host-directory-picker + +[English](README.md) | 中文 + +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 + +浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 + +## 模型体验 + +无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。 + +#### KV 缓存影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。 diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json new file mode 100644 index 0000000000..17d68c141b --- /dev/null +++ b/packages/host/directory-picker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-host-directory-picker", + "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", + "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": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts new file mode 100644 index 0000000000..5e184b36e1 --- /dev/null +++ b/packages/host/directory-picker/src/index.ts @@ -0,0 +1,118 @@ +/** + * The `ctx.directoryPicker` seam: how the web-GUI host lets an operator + * select a workspace directory. Backends differ in interaction shape, not + * just mechanism, so the service exposes a discriminated capability instead + * of one method set: a `dialog` backend opens one native OS chooser on the + * host's display, while a `browse` backend serves listing/creation primitives + * for an in-app browser (and thereby works for remote clients no OS dialog + * can reach). Consumers switch on `capability().kind`; the union is + * merge-extensible, and the documented default for an unknown kind is to + * hide the picking affordance rather than fail. + * @module @deepseek-ai/dsh-host-directory-picker + */ + +import { Context, Service } from 'cordis' + +/** The dialog interaction: one native OS directory chooser on the host display. */ +export interface DirectoryPickerDialogCapability { + kind: 'dialog' + /** + * Open the chooser and wait for the operator. + * @param signal - caller/connection lifetime; abort terminates the chooser. + * @returns the chosen absolute path, or null when the operator cancels. + */ + pick(signal: AbortSignal): Promise +} + +/** One directory row: a listing child or a breadcrumb ancestor. */ +export interface DirectoryEntry { + /** Base name shown in a browser row (a root crumb carries its full path). */ + name: string + /** Absolute host path — clients never join path segments themselves. */ + path: string + /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */ + hidden: boolean +} + +/** One directory level plus its ancestry, as a browse backend reports it. */ +export interface DirectoryListing { + /** Absolute path of the listed directory. */ + path: string + /** The host account's home directory (breadcrumb "Home" rooting). */ + home: string + /** + * Ancestor chain from the filesystem root to the listed directory + * inclusive; every crumb is a jump target (crumb `hidden` is always false). + */ + crumbs: DirectoryEntry[] + /** Direct child directories, name-sorted; symlinks to directories included. */ + entries: DirectoryEntry[] +} + +/** + * The browse interaction: listing/creation primitives an in-app browser + * drives one level at a time. Works for remote clients — nothing renders on + * the host display. + */ +export interface DirectoryPickerBrowseCapability { + kind: 'browse' + /** + * List one directory level. + * @param path - absolute directory to list; absent lists the home directory. + * @returns the level's listing with ancestry. + * @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed. + */ + list(path?: string): Promise + /** + * Create one child directory under an existing parent. + * @param path - absolute existing parent directory. + * @param name - single non-blank path segment (no separators, not `.`/`..`). + * @returns the created directory's absolute path. + * @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise. + */ + createDirectory(path: string, name: string): Promise +} + +/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */ +export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability + +/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ +export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed' + +/** Typed failure thrown by browse primitives so consumers can map business codes without string matching. */ +export class DirectoryPickerError extends Error { + /** + * @param code - closed business code of the failure. + * @param path - the absolute path the failure is about. + * @param message - operator-facing description. + */ + constructor(readonly code: DirectoryPickerErrorCode, readonly path: string, message: string) { + super(message) + this.name = 'DirectoryPickerError' + } +} + +declare module 'cordis' { + interface Context { + directoryPicker: DirectoryPicker + } +} + +/** + * Abstract directory-picking service. Subclass, implement `capability()`, and + * load the subclass as a plugin — it registers as `ctx.directoryPicker` (one + * implementation per context; loading a second throws, cordis' standard + * duplicate-service behavior). The capability object must be stable for the + * service lifetime: consumers may capture it across calls. + */ +export abstract class DirectoryPicker extends Service { + constructor(ctx: Context) { + super(ctx, 'directoryPicker') + } + + /** + * The backend's interaction capability. + * @returns the discriminated capability consumers switch on. + */ + abstract capability(): DirectoryPickerCapability +} diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts new file mode 100644 index 0000000000..9128850f3b --- /dev/null +++ b/packages/host/directory-picker/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker' + +/** Cordis companion plugin name. */ +export const name = 'host-directory-picker-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: this stateless seam owns the capability vocabulary, while backends and the RPC consumer own observations. */ +const install: InvariantInstaller = () => {} + +/** + * Register the directory-picker 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)) diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts new file mode 100644 index 0000000000..4e7a799fa4 --- /dev/null +++ b/packages/host/directory-picker/tests/seam.spec.ts @@ -0,0 +1,35 @@ +/** Contract behavior the seam itself owns: registration identity and typed failures. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts' +import type { DirectoryPickerCapability } from '../src/index.ts' + +/** Minimal concrete backend: all a subclass owes the abstract class is capability(). */ +class StubPicker extends DirectoryPicker { + private readonly stub: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null } + capability(): DirectoryPickerCapability { + return this.stub + } +} + +describe('DirectoryPicker seam', () => { + it('registers a subclass as ctx.directoryPicker and leaves with its fiber', async () => { + const ctx = new Context() + const fiber = ctx.plugin(StubPicker) + await fiber.await() + expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker) + expect(ctx.get('directoryPicker')!.capability().kind).toBe('dialog') + await fiber.dispose() + expect(ctx.get('directoryPicker')).toBeUndefined() + }) + + it('carries the business code and subject path on DirectoryPickerError', () => { + const failure = new DirectoryPickerError('directory-exists', '/home/u/x', '/home/u/x already exists') + expect(failure.name).toBe('DirectoryPickerError') + expect(failure.code).toBe('directory-exists') + expect(failure.path).toBe('/home/u/x') + expect(failure.message).toContain('already exists') + expect(failure).toBeInstanceOf(Error) + }) +}) diff --git a/packages/host/directory-picker/tsconfig.json b/packages/host/directory-picker/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/host/directory-picker/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8343b2404..a47a1ea140 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-dialog': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-dialog '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -2652,6 +2655,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2699,6 +2705,41 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/directory-picker: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/host/directory-picker-browse: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/host/directory-picker-dialog: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/webserver: dependencies: schemastery: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 86ef6d8477..8b4f0cf6fd 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -226,6 +226,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts', CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts', + DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1d4a7e447d..1348ba425a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -408,6 +408,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['spill-policy'], note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.', }, + { + key: 'directoryPicker', + pkg: 'directory-picker', + title: 'Workspace-directory picking seam', + mode: 'seam', + implementations: ['directory-picker-dialog', 'directory-picker-browse'], + consumers: ['apiproxy'], + note: 'Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe.', + }, { key: 'httpServer', pkg: 'webserver', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 13dc2def8d..79100a4c9e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -73,6 +73,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, + 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' }, + 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, + 'packages/host/directory-picker-dialog': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index d4beec7afa..51b6e938dd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -99,6 +99,12 @@ // tsconfig.client.json) stay explicit — TS project references have no // wildcard form. "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], + "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], + "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], + "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], + "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], + "@deepseek-ai/dsh-host-directory-picker-dialog": ["./packages/host/directory-picker-dialog/src"], + "@deepseek-ai/dsh-host-directory-picker-dialog/*": ["./packages/host/directory-picker-dialog/src/*"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 9cf2a86bda..98a51dda19 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -163,6 +163,9 @@ { "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/mcp/mcp-client" }, { "path": "./packages/host/apiproxy" }, + { "path": "./packages/host/directory-picker" }, + { "path": "./packages/host/directory-picker-browse" }, + { "path": "./packages/host/directory-picker-dialog" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, From b9cbe2f029fedf77222d9f02b8cfe2b9cfc30f14 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:57:02 +0800 Subject: [PATCH 008/153] fix(connection): fail the load on a trustedHosts entry that is not a bare authority WHATWG parsing would quietly read a hostname out of harness.internal/path or user@harness.internal, authorizing the typo's hostname; other typos would sit silently ignored until requests 403. Refuse every URL part beyond host[:port] at plugin load. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 ++-- .../2026-07-28-api-browser-trust-boundary.md | 2 +- ...026-07-28-api-browser-trust-boundary.zh.md | 2 +- docs/config-catalog.md | 3 ++- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 15 ++++++++++++ packages/client/connection/src/index.ts | 8 +++++-- .../tests/api-request-trust.spec.ts | 13 +++++++++- .../client/connection/tests/node-half.spec.ts | 24 +++++++++++++++++++ 11 files changed, 67 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 68473ee893..1e10e92f49 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 45a332fcfe59fb930a85cfc595dd02c5fe12a5d7 -2026-07-28-api-browser-trust-boundary.zh.md: 731d6c81f71a2f50b716e52e278f2c53ad62a04b +2026-07-28-api-browser-trust-boundary.md: 4dd913bb73da3b24073c020ff80fdfa83b44a812 +2026-07-28-api-browser-trust-boundary.zh.md: 0be817aca9dde68588959d2cd622639d90d9f993 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 45a332fcfe..4dd913bb73 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo. `host.pickDirectory` loses its bespoke guard and rides the same fence. Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 731d6c81f7..0be817aca9 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6155c418e0..5a7f63c85f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -282,7 +282,8 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 8310adac83..f0775848d3 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 94b9b3c8d4bde30cedf56e31d83efe9f5f1dd87c -README.zh.md: 844a2ef030378c32994f7459792db98c779f24b7 +README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93 +README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 94b9b3c8d4..591e8361c1 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 844a2ef030..bd772b2ab0 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯 `host[:port]` 权威的 `trustedHosts` 条目会让插件加载大声失败——否则 WHATWG 解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 11dc6d7621..2a8b8d7273 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -40,6 +40,21 @@ function parseAuthority(authority: string): URL | undefined { } } +/** + * Assert one configured `trustedHosts` entry is a bare authority (`host` or + * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname + * out of `harness.internal/path` or `user@harness.internal` — a typo must fail + * the load loudly instead of authorizing its hostname or being ignored until + * requests 403. The delimiter test refuses every URL part beyond the authority + * (path, backslash path, query, fragment, userinfo); IPv6 brackets use none of + * them. + * @param entry - the configured value, verbatim. + */ +export function assertTrustedAuthority(entry: string): void { + if (parseAuthority(entry) !== undefined && !/[/\\?#@]/.test(entry)) return + throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) +} + /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 1bc39ed3ba..f37a64fb21 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -6,7 +6,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' -import { isTrustedApiRequest } from './api-request-trust.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -23,7 +23,8 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). + * by (the dsh CLI derives the machine's LAN IP literals itself). An entry + * that is not a bare authority fails the plugin load. */ trustedHosts?: string[] } @@ -42,6 +43,9 @@ export const Config: z = z.object({ export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] + // Config boundary: a malformed entry fails the load loudly here rather than + // silently authorizing its hostname prefix at request time. + for (const entry of trustedHosts) assertTrustedAuthority(entry) const apiHandler = toFetchHandler(ctx.apiProxy) const route: WebRoute = { kind: 'prefix', diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 5dc2d14b1b..99df0d86eb 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -1,7 +1,7 @@ /** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */ import { describe, expect, it } from 'vitest' -import { isTrustedApiRequest } from '../src/api-request-trust.ts' +import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts' function request(headers: Record): { headers: Record } { return { headers } @@ -66,6 +66,17 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { + for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { + expect(() => { assertTrustedAuthority(entry) }).not.toThrow() + } + // WHATWG parsing would quietly read a hostname out of each of these; the + // config boundary must refuse them instead of authorizing the prefix. + for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + it('refuses malformed or untrusted authorities on browser requests', () => { const markers = { 'sec-fetch-site': 'same-origin' } expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 59c8c34ce2..5404f1c798 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -54,6 +54,30 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: } describe('connection node half', () => { + it('fails the load on a trustedHosts entry that is not a bare authority', async () => { + const routes: WebRoute[] = [] + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) + // The apply throw also escapes cordis as a late rejection — the shape the + // boot's installFailLoud is contracted to catch. Capture it so the run + // stays clean, same pattern as the webserver bind-failure test. + const rejections: unknown[] = [] + const onUnhandled = (err: unknown): void => { rejections.push(err) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) + await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/) + expect(routes).toHaveLength(0) + for (let i = 0; i < 100 && rejections.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority') + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('registers the /api prefix route and removes it with the fiber', async () => { const { routes, dispose } = await mounted() expect(routes).toHaveLength(1) From 34518cb012d3d9a16ec851b4a215db7100f4457a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:19:31 +0800 Subject: [PATCH 009/153] fix(connection): judge an entry's explicit port from the parsed URL, not the raw string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHATWG trimming strips stray whitespace before parsing, so 'host:port ' passed the load assert while the raw-string port regex read it as port-less — broadening an exact-port grant to every port on that hostname. The explicit- port judgment now reads URL parses under both special schemes (:80/:443 stay explicit), and the load assert refuses whitespace outright. --- .../connection/src/api-request-trust.ts | 21 +++++++++++++++---- .../tests/api-request-trust.spec.ts | 12 +++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 2a8b8d7273..ad2519d90b 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -45,16 +45,29 @@ function parseAuthority(authority: string): URL | undefined { * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname * out of `harness.internal/path` or `user@harness.internal` — a typo must fail * the load loudly instead of authorizing its hostname or being ignored until - * requests 403. The delimiter test refuses every URL part beyond the authority - * (path, backslash path, query, fragment, userinfo); IPv6 brackets use none of + * requests 403. The character test refuses every URL part beyond the authority + * (path, backslash path, query, fragment, userinfo) and all whitespace, which + * WHATWG trimming would otherwise strip silently; IPv6 brackets use none of * them. * @param entry - the configured value, verbatim. */ export function assertTrustedAuthority(entry: string): void { - if (parseAuthority(entry) !== undefined && !/[/\\?#@]/.test(entry)) return + if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) } +/** + * Whether the parsed authority carries an explicit port: judged from URL + * parses under both special schemes (their default ports differ, so `:80` and + * `:443` still count as explicit), never from the raw string, where WHATWG + * trimming of stray whitespace would misread `host:port ` as port-less and + * broaden an exact-port grant to every port. + */ +function hasExplicitPort(entry: string, entryUrl: URL): boolean { + // An authority that parsed under http cannot fail under https. + return entryUrl.port !== '' || new URL(`https://${entry}`).port !== '' +} + /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the @@ -66,7 +79,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool return trustedHosts.some((entry) => { const entryUrl = parseAuthority(entry) if (entryUrl === undefined) return false - return /:\d+$/.test(entry) + return hasExplicitPort(entry, entryUrl) ? entryUrl.host === hostUrl.host : entryUrl.hostname === hostUrl.hostname }) diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 99df0d86eb..e3f1c91caf 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -75,6 +75,18 @@ describe('isTrustedApiRequest', () => { for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) { expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) } + // WHATWG trimming would silently strip these; the entry must fail instead. + for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } + }) + + it('never lets stray whitespace broaden an exact-port entry to every port', () => { + // Defense in depth below the load-time assert: the explicit-port judgment + // reads the parsed URL, so a trimmed `host:port ` entry stays exact. + const trusted = ['harness.internal:3080 '] + expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true) }) it('refuses malformed or untrusted authorities on browser requests', () => { From f4c243c75fa2590ddba00273d4c0b12a393a5e5a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 28 Jul 2026 16:41:04 +0800 Subject: [PATCH 010/153] feat(web): state the run state on the terminal card's prompt line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal card showed no run state: a running command and a settled command that produced no output rendered the same prompt line, so whether a command was still running had to be inferred from the absence of output. Lead the prompt line with a StateDot in three of its states — the spinning ring while running, red for the same exit status that renders the status pill, green for a clean settle. That is the same indicator a tool row's leading icon carries, so a row and its own card cannot disagree about one command; the row/card agreement is pinned in the ui-conversation spec. StateDot is aria-hidden, so a visually hidden text label rides beside it, which is what the refreshed aria goldens now record. The e2e adds what jsdom cannot compute: the dot's color resolves to the green success token through the real theme stylesheet, and the dot precedes the prompt label in document order. --- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 8 +-- .../2026-07-28-web-terminal-card.zh.md | 8 +-- apps/web/tests/navigation-panes.e2e.ts | 28 ++++++++++ .../navigation-panes/details-open.expected.md | 2 +- .../terminal-card.expected.md | 2 +- apps/web/tests/terminal-card.snapshot.ts | 13 ++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../tests/terminal-card.spec.tsx | 22 ++++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/StateDot.tsx | 4 +- .../src/TerminalBlock.module.css | 17 ++++++ .../ui-primitives/src/TerminalBlock.tsx | 33 +++++++++++- .../tests/terminal-block.spec.tsx | 54 ++++++++++++++++++- 18 files changed, 184 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 347d5978c0..98cbad4ffc 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 76d5c47054378330da9e9eebb70571925e47f741 -2026-07-28-web-terminal-card.zh.md: 83d5e7f72d9b04358ce4fe1fd9295e5c97045031 +2026-07-28-web-terminal-card.md: 7dbe688b58e78a8f67fe14809b86a6bfd4f7ce36 +2026-07-28-web-terminal-card.zh.md: 1e8c847e2955605be1a55b45d84fb449ea2a46fd diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 76d5c47054..7dbe688b58 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -16,7 +16,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ The component's contract: -- **Prompt line.** A shortened cwd label followed by the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. +- **Prompt line.** A run-state dot, then a shortened cwd label, then the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. The dot is `StateDot` in three of its four states: the spinning ring while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. It leads the line because the first question a reader has about a shell command is whether it is still running, and without the dot that had to be inferred from the absence of output — which a settled command producing no output also looks like. `StateDot` is `aria-hidden`, so a visually hidden text label rides beside it. - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding. - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends. - **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output. @@ -50,13 +50,13 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. -`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. +`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. -`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one. +`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet), and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 83d5e7f72d..1e8c847e29 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -16,7 +16,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c 该组件的契约: -- **提示符行。** 一个缩短的 cwd 标签,其后原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。 +- **提示符行。** 一枚运行状态点,其后是缩短的 cwd 标签,再原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。状态点是 `StateDot` 四种状态中的三种:运行期间为旋转圆环,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。它位于行首,因为读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有该状态点时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。`StateDot` 是 `aria-hidden`,因此其旁伴随一处视觉隐藏的文本标签。 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。 - **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。 @@ -50,13 +50,13 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 -`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 +`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 -`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。 +`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值);复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。 ## Related diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index b0e340da43..1e022489ee 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -207,6 +207,34 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed } }) expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true }) + // The run-state dot's color is the whole point of it and is the one thing + // jsdom cannot report: --dsw-* tokens resolve only against the real theme + // stylesheet. This command settled cleanly, so the dot must be the green + // success token — a red one here would read as a failed command. + const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => { + // The token lives on body, so the probe must sit in the same cascade. + const probe = document.createElement('span') + probe.style.color = 'var(--dsw-alias-state-success-primary)' + document.body.appendChild(probe) + const success = getComputedStyle(probe).color + probe.remove() + return { + state: node.getAttribute('data-state'), + color: getComputedStyle(node as HTMLElement).color, + success, + label: node.parentElement?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null, + // The dot precedes the prompt label in document order, which is what + // puts it to the left of the `$`. + beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!) + === Node.DOCUMENT_POSITION_FOLLOWING, + } + }) + expect(dot.state).toBe('done') + expect(dot.label).toBe('已完成') + expect(dot.beforePrompt).toBe(true) + // Resolved through the theme token, not a literal hex in the component. + expect(dot.success).toMatch(/^rgb/) + expect(dot.color).toBe(dot.success) // Golden of the card at rest — captured before the copy click, whose // confirmation label self-reverts on a timer and would not hold still. const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md index 9eee71468d..d0a753994a 100644 --- a/apps/web/tests/snapshots/navigation-panes/details-open.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/details-open.expected.md @@ -3,6 +3,6 @@ - text: Input json - button "复制" - code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }" -- text: Output $ echo NAVIGATION_OK +- text: Output 已完成 $ echo NAVIGATION_OK - button "复制" - text: NAVIGATION_OK diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md index 2b81725468..abeffe93a8 100644 --- a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md @@ -1,3 +1,3 @@ -- text: $ echo NAVIGATION_OK +- text: 已完成 $ echo NAVIGATION_OK - button "复制" - text: NAVIGATION_OK diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts index 53215f4ba9..80cc4fd2b4 100644 --- a/apps/web/tests/terminal-card.snapshot.ts +++ b/apps/web/tests/terminal-card.snapshot.ts @@ -120,9 +120,14 @@ function readCard(card: Element) { text: expander.textContent, expanded: expander.getAttribute('aria-expanded'), }, + // The run-state dot at the head of the prompt line, by its StateDot state. + runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null, + runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null, // Every color the ANSI parser emits resolves through a --dsw-* token, so // the card follows the theme instead of painting literal terminal rgb. - colors: [...new Set([...card.querySelectorAll('span[style]')] + // Scoped to the output lines: the run-state dot is an inline-styled span + // too, and its geometry is not an ANSI-resolved color. + colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')] .map(span => span.getAttribute('style')))], } } @@ -198,6 +203,8 @@ it('renders the keyed bash row with a resident terminal card', async () => { "[exit code: 1]", ], "prompt": "nested pnpm run check", + "runState": "error", + "runStateLabel": "失败", "status": "退出码 1", } `) @@ -229,6 +236,8 @@ it('the fallback row reaches the same card through its expand control', async () "-rw-r--r-- demo.txt", ], "prompt": "fixture ls -la", + "runState": "done", + "runStateLabel": "已完成", "status": null, } `) @@ -318,6 +327,8 @@ it('the details panel Output section renders the same call at full height', asyn ], "panelLines": 16, "prompt": "nested pnpm run check", + "runState": "error", + "runStateLabel": "失败", "status": "退出码 1", } `) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index dd28591b01..677029cf5f 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: 1c6912b05e259fa1f4a7096c3a2b82f9f67f5527 -README.zh.md: 157bfafd3a1157420acbb73861cd40d759228743 +README.md: 39d185014c658f490f0a3672ea3d7c99f30d8df2 +README.zh.md: 6b63631287bf420af0985a743f068f27b9c97873 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 1c6912b05e..39d185014c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -10,7 +10,7 @@ The view ring IS a slot: the conversation registration declares the `'conversati 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 `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. 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), and the details panel resolves a selected sub-call id to its full logged args and complete output. 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. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it 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)). +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. The keyed `BashRow` carries the card resident below its summary row and outside that row's click target, so copying or expanding the output does not open the details panel; the render-site fallback row keeps it 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`/`openDetails`) 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). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 157bfafd3a..6b63631287 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -10,7 +10,7 @@ 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。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,落回通用路径。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 +声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。键控的 `BashRow` 把卡片常驻在摘要行下方、且位于该行点击目标之外,因此复制或展开输出不会打开详情面板;渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 1ab75861b5..49fcf48d4c 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -28,6 +28,11 @@ afterEach(cleanup) */ const RAW = { normalizer: (text: string) => text } +/** The rendered card's run-state dot state, so a render site cannot silently drop it. */ +function runStateOf(container: HTMLElement): string | null { + return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null +} + const SID = 's1' as SessionId const ARGS = '{"command":"ls -la","description":"List files"}' @@ -137,6 +142,9 @@ describe('chat row terminal body', () => { fireEvent.click(view.container.querySelector('button')!) expect(view.getByText('ls -la')).toBeTruthy() expect(view.queryByText('复制')).toBeNull() + // The card states its own run state: a running command reads as running + // even though it has no output yet to distinguish it from an empty settle. + expect(runStateOf(view.container)).toBe('ongoing') }) it('a non-terminal call keeps the args-JSON text body', () => { @@ -183,6 +191,19 @@ describe('BashRow terminal card', () => { expect(openDetails).toHaveBeenCalledTimes(1) }) + // The row's leading StateDot and the card's run-state dot describe the same + // command, so a running row whose card claimed 'done' would be a contradiction + // the reader sees on one line. + it('agrees with the summary row about the run state', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running') + expect(runStateOf(runningView.container)).toBe('ongoing') + cleanup() + const settledView = render() + expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok') + expect(runStateOf(settledView.container)).toBe('done') + }) + it('a non-terminal bash call (background start) renders the summary row alone', () => { const view = render( { const view = mount(snapshot({ runningCalls: [running()] }), target) expect(view.getByText('ls -la')).toBeTruthy() expect(view.queryByText('运行中…')).toBeNull() + expect(runStateOf(view.container)).toBe('ongoing') }) it('a running non-terminal call keeps the 运行中… placeholder', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 1eb8a05ba0..78281969f3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: c9c70f29804ac4e6783486595460bf07499e1dff -README.zh.md: 254fc5ba5aef553fd447338353a0c5311ddbd98a +README.md: b0387debbecb713b1e4af2b1497e81ddda083a51 +README.zh.md: d04a34951422c9cdd02421759a4e29672b1f7a54 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c9c70f2980..b0387debbe 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: a prompt line (shortened `cwd` label plus the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: a prompt line (a run-state `StateDot` ahead of the shortened `cwd` label, then the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. The dot reaches three of `StateDot`'s states — the spinning ring while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries a visually hidden text label because `StateDot` is `aria-hidden`. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 254fc5ba5a..d04a349514 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签加命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签之前是一枚运行状态 `StateDot`,其后是命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。该状态点用到 `StateDot` 的三种状态——`running` 期间为旋转圆环,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它同时携带一处视觉隐藏的文本标签。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index c4117673ba..6bc4cdba5c 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -19,8 +19,8 @@ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' */ export function StateDot({ state, size = 10, className }: { state: StateDotState - size?: number - className?: string + size?: number | undefined + className?: string | undefined }) { const gradientId = useId() if (state === 'ongoing') { diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 8a4f4a5e60..6d470ec634 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -36,6 +36,23 @@ font: var(--dsw-font-markdown-code-block); } +/* The dot sits on the prompt row's baseline box, which is a code-font line, so + it is centered against that line's box rather than sitting on the baseline. */ +.runState { + flex: none; + align-self: center; +} + +/* The dot is aria-hidden; this is its text label for assistive technology. */ +.runStateLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + .cwd { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index e224e2b1de..5431f3c443 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -1,6 +1,6 @@ // TerminalBlock: the terminal surface for a shell command and its output — -// prompt line (shortened cwd + command), ANSI-colored output, settled exit -// status, and a copy control for the raw output. Output never soft-wraps: +// prompt line (run-state dot + shortened cwd + command), ANSI-colored output, +// settled exit status, and a copy control for the raw output. Output never soft-wraps: // column-aligned output (ls, tables, box drawing) keeps its alignment and // scrolls horizontally instead of folding. Colors resolve through --dsw-* // tokens; ANSI parsing lives in ansi.ts. @@ -10,6 +10,7 @@ import clsx from 'clsx' import { parseAnsiLines, type AnsiLine } from './ansi.ts' import { writeClipboard } from './clipboard.ts' import { Pill } from './Pill.tsx' +import { StateDot, type StateDotState } from './StateDot.tsx' import css from './TerminalBlock.module.css' /** @@ -70,6 +71,31 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s return undefined } +/** + * Run-state indicator for the command, shown at the head of the prompt line so + * the card states whether the command is still running without the reader + * having to infer it from the presence of output. Three of {@link StateDotState}'s + * four states are reachable: the spinning ring while running (the same + * indicator a running tool row's leading icon uses, so the row and its card + * never disagree), green for a clean settle, red for a signal or a non-zero + * exit — the same status distinction {@link statusText} draws for the pill. A + * settled command whose exit status never reached the view counts as a clean + * settle: the view says it finished and says nothing went wrong. + * @param running - the command has not settled. + * @param exitCode - settled exit code, when known. + * @param signal - settled terminating signal name, when known. + * @returns the dot's state and its text label, since the dot is aria-hidden. + */ +function runState( + running: boolean, + exitCode: number | undefined, + signal: string | undefined, +): { state: StateDotState; label: string } { + if (running) return { state: 'ongoing', label: '运行中' } + if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' } + return { state: 'done', label: '已完成' } +} + /** * Render one parsed output line. Runs without SGR state render as bare text, * so uncolored output carries no span wrappers. @@ -120,6 +146,7 @@ export function TerminalBlock({ const onToggle = useCallback(() => { setExpanded(value => !value) }, []) const status = statusText(exitCode, signal) + const state = runState(running, exitCode, signal) const empty = text.trim() === '' const hidden = lines.length - maxLines const capped = hidden > 0 && !expanded @@ -132,6 +159,8 @@ export function TerminalBlock({
+ + {state.label} {cwd === undefined ? '$' : promptLabel(cwd, home)} {command}
diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx index 87e153a741..fd928f48d5 100644 --- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // TerminalBlock: the prompt label's cwd shortening, the running/empty/settled -// arms, the exit-status pill, the head/tail height cap and its expand control, +// arms, the prompt line's run-state dot, the exit-status pill, the head/tail height cap and its expand control, // and the copy control writing the raw output on both the accepted and the // refused clipboard paths. writeClipboard's own return contract is pinned here // too, since it is the seam both copy controls in this package share; the @@ -25,6 +25,15 @@ function outputLines(container: HTMLElement): string[] { return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '') } +/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */ +function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } { + const dot = container.querySelector('[class*="_runState_"][data-state]') + return { + state: dot?.getAttribute('data-state') ?? null, + label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined, + } +} + /** `count` numbered output lines, without the terminating newline. */ function body(count: number): string { return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n') @@ -128,7 +137,8 @@ describe('TerminalBlock states', () => { it('renders ANSI runs as styled spans and plain text bare', () => { const view = render() - const span = view.container.querySelector('span[style]') + // Scoped to a line: the prompt line's run-state dot is a styled span too. + const span = view.container.querySelector('[class^="_line_"] span[style]') expect(span?.textContent).toBe('bad') expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary') expect(outputLines(view.container)).toEqual(['bad ok']) @@ -163,6 +173,46 @@ describe('TerminalBlock status pill', () => { }) }) +describe('TerminalBlock run-state dot', () => { + it('shows the spinning ring and its running label while the command runs', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' }) + }) + + it('shows the done dot for a clean settled exit', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' }) + }) + + it('counts a settled command with no exit status as a clean settle', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' }) + }) + + it('shows the error dot for a non-zero exit', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' }) + }) + + it('shows the error dot for a signal, whatever the exit code says', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' }) + }) + + // The dot precedes the prompt label, which is what makes it read as the + // state OF this command rather than of the card's chrome. + it('places the dot ahead of the prompt label and the command', () => { + const view = render() + const prompt = view.container.querySelector('[class^="_prompt_"]') + expect([...prompt!.children].map(node => node.textContent)).toEqual(['', '已完成', 'app', 'ls']) + }) + + it('keeps the running dot even while a settled-looking status pill is supplied', () => { + const view = render() + expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' }) + }) +}) + describe('TerminalBlock height cap', () => { it('renders every line and no expand control under the cap', () => { const view = render() From 716d3ca6361a24df610d2a6840fecc8aea8f2cf4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:44:22 +0800 Subject: [PATCH 011/153] test(apiproxy): drive the browse RPCs through the fetch carrier The full-suite coverage gate found the new listDirectory/createDirectory client methods and handler routes unexecuted: the implementation and schema layers were tested directly, but nothing crossed the wire form. One round trip through InProcessApiClient covers both arrows on each side. --- packages/host/apiproxy/tests/fetch-carrier.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index a0645f4750..6340e4e2e7 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -211,6 +211,19 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) }) + it('round-trips the browse listing and creation calls through the wire form', async () => { + const c = client() + const listed = await c.host.listDirectory({ path: '/w' }) + expect(listed.result).toEqual({ + ok: true, + value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }, + }) + const home = await c.host.listDirectory({}) + expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) + const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) + expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) From 7ff8da56dfb8e6ad0130e9dcbdb55f034c3ff530 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:49:52 +0800 Subject: [PATCH 012/153] fix(connection): require trustedHosts entries in canonical authority form A dangling colon (harness.internal:) or zero-padded port parses cleanly while WHATWG silently rewrites it, turning an intended exact-port grant into an any-port grant. Replace the character blacklist with a round-trip check: an entry must read back from parsing exactly as written (case aside), refusing the whole rewrite class at load. --- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 41 +++++++++++-------- packages/client/connection/src/index.ts | 2 +- .../tests/api-request-trust.spec.ts | 6 +++ 7 files changed, 35 insertions(+), 24 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5a7f63c85f..878de38a19 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -283,7 +283,7 @@ export interface ConnectionConfig { * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare authority fails the plugin load. + * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index f0775848d3..c390223071 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 591e8361c1d28fab909bfe4a4f176fa1887edd93 -README.zh.md: bd772b2ab0f36abc8dbce35d30f55b40e53d0756 +README.md: 7e437e8fd81d1d57ead5ead64d2049111ad163dc +README.zh.md: 3615d5ea1be44e6da8e41fa17f645eb414d1ef3e diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 591e8361c1..7e437e8fd8 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare `host[:port]` authority fails the plugin load loudly — WHATWG parsing would otherwise quietly authorize the hostname inside a typo like `harness.internal/path`. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index bd772b2ab0..3615d5ea1b 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯 `host[:port]` 权威的 `trustedHosts` 条目会让插件加载大声失败——否则 WHATWG 解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ad2519d90b..57a8cb179c 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -42,30 +42,35 @@ function parseAuthority(authority: string): URL | undefined { /** * Assert one configured `trustedHosts` entry is a bare authority (`host` or - * `host:port`) and nothing else. WHATWG parsing would quietly read a hostname - * out of `harness.internal/path` or `user@harness.internal` — a typo must fail - * the load loudly instead of authorizing its hostname or being ignored until - * requests 403. The character test refuses every URL part beyond the authority - * (path, backslash path, query, fragment, userinfo) and all whitespace, which - * WHATWG trimming would otherwise strip silently; IPv6 brackets use none of - * them. + * `host:port`) in canonical form: it must survive WHATWG parsing unchanged + * (case aside). Anything parsing would silently rewrite is refused as a typo + * that must fail the load loudly instead of being ignored until requests 403 + * or quietly changing the grant: URL parts beyond the authority + * (`harness.internal/path`, `user@harness.internal` — which would authorize + * the embedded hostname), stripped whitespace, a dangling colon or + * zero-padded port (which would broaden an intended exact-port grant to every + * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, + * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire + * carries). * @param entry - the configured value, verbatim. */ export function assertTrustedAuthority(entry: string): void { - if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return + const entryUrl = parseAuthority(entry) + if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) } /** - * Whether the parsed authority carries an explicit port: judged from URL - * parses under both special schemes (their default ports differ, so `:80` and - * `:443` still count as explicit), never from the raw string, where WHATWG - * trimming of stray whitespace would misread `host:port ` as port-less and - * broaden an exact-port grant to every port. + * Canonical form of a parsed authority: `hostname` when no port was written, + * else `hostname:port`. The port is judged from URL parses under both special + * schemes (their default ports differ, so `:80` and `:443` still count as + * explicit), never from the raw string, where WHATWG trimming would misread + * shapes like `host:port ` as port-less. */ -function hasExplicitPort(entry: string, entryUrl: URL): boolean { +function canonicalAuthority(entry: string, entryUrl: URL): string { // An authority that parsed under http cannot fail under https. - return entryUrl.port !== '' || new URL(`https://${entry}`).port !== '' + const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port + return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` } /** @@ -79,9 +84,9 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool return trustedHosts.some((entry) => { const entryUrl = parseAuthority(entry) if (entryUrl === undefined) return false - return hasExplicitPort(entry, entryUrl) - ? entryUrl.host === hostUrl.host - : entryUrl.hostname === hostUrl.hostname + return canonicalAuthority(entry, entryUrl) === entryUrl.hostname + ? entryUrl.hostname === hostUrl.hostname + : entryUrl.host === hostUrl.host }) } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f37a64fb21..a649afda1a 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -24,7 +24,7 @@ export interface ConnectionConfig { * browser request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare authority fails the plugin load. + * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] } diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index e3f1c91caf..2c608cc988 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -79,6 +79,12 @@ describe('isTrustedApiRequest', () => { for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) { expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) } + // WHATWG parsing would silently rewrite these — a dangling colon or + // zero-padded port would broaden an intended exact-port grant to every + // port, and non-canonical host spellings would not read back as written. + for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) { + expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/) + } }) it('never lets stray whitespace broaden an exact-port entry to every port', () => { From 772653464d546121c90c5d4fc77337d9716a513f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:02:39 +0800 Subject: [PATCH 013/153] =?UTF-8?q?fix(connection):=20hold=20markerless=20?= =?UTF-8?q?requests=20to=20the=20Host=20fence=20=E2=80=94=20plain-HTTP=20b?= =?UTF-8?q?rowser=20reads=20carry=20no=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fetch-Metadata and Origin are only attached to trustworthy destinations, so over plain HTTP a rebound page's same-origin GET (EventSource, images, navigations) arrives with no browser markers and a readable response. Remove the marker shortcut; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. --- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 4 +- ...026-07-28-api-browser-trust-boundary.zh.md | 4 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../connection/src/api-request-trust.ts | 40 ++++++++++--------- packages/client/connection/src/index.ts | 2 +- .../tests/api-request-trust.spec.ts | 16 ++++---- .../client/connection/tests/node-half.spec.ts | 6 +-- 11 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 1e10e92f49..c15af141bd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.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-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 4dd913bb73da3b24073c020ff80fdfa83b44a812 -2026-07-28-api-browser-trust-boundary.zh.md: 0be817aca9dde68588959d2cd622639d90d9f993 +2026-07-28-api-browser-trust-boundary.md: e56d0fc2a7bd551899605491f3a0522b62b961b0 +2026-07-28-api-browser-trust-boundary.zh.md: 2958f7e49bfd4a258c63fc96c2e8aee0f98183ee diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 4dd913bb73..e56d0fc2a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -13,7 +13,7 @@ The web GUI host serves `/api` over plain HTTP (default `127.0.0.1:3080`, `--hos Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves in two stacked PRs: - **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. -- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: requests without browser markers (no `Origin`, no `sec-fetch-site`) pass on any Host — a non-browser client is the principal itself, not a deputy, and forges every header anyway, so fencing it buys nothing and breaks non-browser LAN automation. For browser requests, `Host` must be loopback or match a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense); an attached `Origin` must equal that authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo. `host.pickDirectory` loses its bespoke guard and rides the same fence. +- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. Two boundaries stay deliberately out of scope: reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and authentication for genuinely remote deployments is deferred work recorded in the connection README — the fence is a confused-deputy defense, not an auth layer. The old guard's loopback-socket check was dropped rather than generalized: with binding expressing reachability and `trustedHosts` naming remote authorities, the socket address adds nothing a header fence does not already cover. @@ -26,6 +26,6 @@ Two boundaries stay deliberately out of scope: reachability is the webserver bin ## Consequences - Any future `/api` method is covered by construction; there is no per-route trust decision left to forget. -- Non-loopback deployments must have their serving authorities trusted or browsers are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Plain curl-shape automation is unaffected everywhere. +- Non-loopback deployments must have their serving authorities trusted or requests are refused. The dsh CLI keeps its advertised `--host 0.0.0.0` LAN URL working by deriving the machine's LAN IP literals into the connection row (port-less entries — an IP-literal Host cannot be a rebound name, and the bound port may be OS-assigned) and offers `dsh web --trusted-host` for named authorities; compositions the CLI does not boot declare `trustedHosts` themselves. Non-browser automation rides the same fence: loopback, a derived LAN IP, or a declared authority passes; an undeclared DNS alias is refused. - Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header). - The trusted-network assumption of an unauthenticated `0.0.0.0` deployment is now documented instead of implicit. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index 0be817aca9..2958f7e49b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -13,7 +13,7 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——两半各占一个栈式 PR: - **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站"简单请求"由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 -- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`)在任何 Host 上都放行——非浏览器客户端是委托人本人,不是代理人,且本就可以伪造任何请求头,对它设栅一无所获,反而会打断非浏览器的 LAN 自动化。对浏览器请求,`Host` 必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御);若带 `Origin` 则必须与该权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 +- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是纯的、规范形权威的 `trustedHosts` 条目会让插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 两条边界刻意留在范围之外:可达性归 webserver 绑定配置(`host: 127.0.0.1 | 0.0.0.0`)管辖;真正远程部署的认证是延期工作,记录在 connection README——这道栅栏是混淆代理人防御,不是认证层。旧守卫的回环 socket 检查被放弃而非泛化:绑定表达可达性、`trustedHosts` 点名远程权威之后,socket 地址提供不了头部栅栏覆盖不到的任何东西。 @@ -26,6 +26,6 @@ Web GUI 宿主以纯 HTTP 提供 `/api`(默认 `127.0.0.1:3080`,支持 `--ho ## 后果 - 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。 -- 非回环部署的服务权威必须获得信任,否则浏览器会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。curl 形态的自动化在任何地方都不受影响。 +- 非回环部署的服务权威必须获得信任,否则请求会被拒绝。dsh CLI 通过把本机 LAN IP 字面量推导进 connection 行(不带端口的条目——IP 字面量 Host 不可能是被重绑的域名,且绑定端口可能由操作系统分配)来保住它广告出的 `--host 0.0.0.0` LAN URL,并提供 `dsh web --trusted-host` 声明具名权威;CLI 不参与引导的组合自行声明 `trustedHosts`。非浏览器自动化走同一道栅栏:回环地址、推导的 LAN IP 或已声明的权威可通过;未声明的 DNS 别名会被拒绝。 - 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。 - 无认证 `0.0.0.0` 部署的"信任网络"假设从隐含变为成文。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 878de38a19..43c9b5c8bf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -280,7 +280,7 @@ export interface ConnectionConfig { /** * Authorities this deployment serves beyond loopback: exact `host:port`, or * port-less `host` matching any port. The /api trust fence refuses any - * browser request whose Host is neither loopback nor listed here, so a + * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached * by (the dsh CLI derives the machine's LAN IP literals itself). An entry * that is not a bare, canonical authority fails the plugin load. diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index c390223071..6b8558f9be 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 7e437e8fd81d1d57ead5ead64d2049111ad163dc -README.zh.md: 3615d5ea1be44e6da8e41fa17f645eb414d1ef3e +README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6 +README.zh.md: ca5da643db443956c25399f07c8b460900942ad4 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 7e437e8fd8..173a9b9998 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -6,7 +6,7 @@ Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared a ## /api browser-trust fence -The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Requests without browser markers (no `Origin`, no `sec-fetch-site` — curl, tests, native clients) pass on any Host: without a browser there is no confused deputy, and such a sender forges every header anyway. For browser requests, the `Host` header must be a loopback authority or match a `trustedHosts` entry — exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense); an attached `Origin` must equal that authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3615d5ea1b..ca5da643db 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -6,7 +6,7 @@ ## /api 浏览器信任栅栏 -node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。不带浏览器标记的请求(无 `Origin`、无 `sec-fetch-site`——curl、测试、原生客户端)在任何 Host 上都放行:没有浏览器就不存在"混淆代理人",且这类发送方本就可以伪造任何请求头。对浏览器请求,`Host` 头必须是回环地址权威,或与某个 `trustedHosts` 条目匹配——带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御);若带有 `Origin` 则必须与该权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 ## 无密钥 fixture diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index 57a8cb179c..8c1bddd631 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -2,12 +2,15 @@ * Browser-trust fence for every /api request. Defends the two confused-deputy * paths a browser opens against a local HTTP API — DNS rebinding (Host names * the attacker's domain while the socket reaches this server) and cross-site - * requests fired from a malicious page — without blocking non-browser clients - * (no browser markers → no deputy to confuse, and a native client forges Host - * freely anyway) or legitimately remote browsers (their authority is declared - * via `trustedHosts`, or derived by the composing app for IP-literal LAN - * serving). Network reachability and authentication stay out of scope: binding - * policy belongs to the webserver config, and this fence is not an auth layer. + * requests fired from a malicious page. The Host fence binds every request, + * browser-looking or not: over plain HTTP a browser attaches neither Origin + * nor Fetch-Metadata to reads (EventSource, images, navigations — those + * headers go only to trustworthy destinations), so an unmarked request may + * still be a rebound browser read and Host is the one header rebinding cannot + * forge. Non-browser and remote clients pass the same fence via loopback, the + * CLI-derived LAN IP literals, or a declared `trustedHosts` authority. + * Network reachability and authentication stay out of scope: binding policy + * belongs to the webserver config, and this fence is not an auth layer. */ import type { IncomingHttpHeaders } from 'node:http' @@ -94,19 +97,16 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool * Decide whether one /api request may reach the RPC bridge. * @param request - node HTTP request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. - * @returns true for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin. + * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { - // Marker gate: Origin and sec-fetch-site exist only when a browser is the - // sender's deputy. Absent both, the sender is the principal itself (curl, - // tests, native shells) and could forge every header below — fencing it - // would add nothing and would break non-browser LAN automation. - const origin = header(request.headers, 'origin') - const secFetchSite = header(request.headers, 'sec-fetch-site') - if (origin === undefined && secFetchSite === undefined) return true - // Host fence (DNS-rebinding defense): the browser fills Host from the URL it - // believes it is talking to, so a rebound page carries the attacker's domain - // here even though the socket lands on this server. + // Host fence (DNS-rebinding defense), applied to every request: the browser + // fills Host from the URL it believes it is talking to, so a rebound page + // carries the attacker's domain here even though the socket lands on this + // server. There is no marker shortcut — a browser read over plain HTTP + // (EventSource, images, navigations) arrives with neither Origin nor + // Fetch-Metadata, indistinguishable from curl, and its response is readable + // by the rebound page. const host = header(request.headers, 'host') if (host === undefined) return false const hostUrl = parseAuthority(host) @@ -114,10 +114,12 @@ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: read if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false // Cross-site fence: modern browsers label the initiator relationship on // every fetch; an explicit cross-site marker is refused regardless of Origin. - if (secFetchSite === 'cross-site') return false + if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false // Origin fence: when a browser attaches an Origin it must be exactly this - // authority (compared through the same normalization as the Host). The + // authority (compared through the same normalization as the Host). Absent + // Origin is fine — the Host fence above already bound the request. The // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. + const origin = header(request.headers, 'origin') if (origin === undefined) return true try { return new URL(origin).host === hostUrl.host diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index a649afda1a..ce55089bdb 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -21,7 +21,7 @@ export interface ConnectionConfig { /** * Authorities this deployment serves beyond loopback: exact `host:port`, or * port-less `host` matching any port. The /api trust fence refuses any - * browser request whose Host is neither loopback nor listed here, so a + * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached * by (the dsh CLI derives the machine's LAN IP literals itself). An entry * that is not a bare, canonical authority fails the plugin load. diff --git a/packages/client/connection/tests/api-request-trust.spec.ts b/packages/client/connection/tests/api-request-trust.spec.ts index 2c608cc988..f145230a8b 100644 --- a/packages/client/connection/tests/api-request-trust.spec.ts +++ b/packages/client/connection/tests/api-request-trust.spec.ts @@ -8,13 +8,15 @@ function request(headers: Record): { headers: Record } describe('isTrustedApiRequest', () => { - it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => { - // No Origin and no sec-fetch-site → the sender is the principal itself - // (it forges Host freely anyway); this is the LAN-serving shape a Host - // fence must not break. - for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) { - expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true) - } + it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => { + // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to + // reads (EventSource, images, navigations), so a rebound-origin GET is + // markerless and its response readable: no marker shortcut may exist. + expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true) + expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false) + expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false) + expect(isTrustedApiRequest(request({}), [])).toBe(false) }) it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 5404f1c798..2c7fd0b281 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -98,14 +98,14 @@ describe('connection node half', () => { }) it('passes loopback and declared-authority requests through to the bridge', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080'] }) + const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) // Loopback, no browser markers (curl shape): the fence passes; the carrier // answers 404 for a GET unary path — proof the bridge ran. const loopback = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) expect(loopback.state.status).toBe(404) - // Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl - // shape must reach the bridge even with an empty-by-default trust list. + // LAN authority declared as a port-less IP literal — the shape the CLI + // derives for `--host 0.0.0.0` — passes markerless curl on any port. const lan = fakeResponse() await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) expect(lan.state.status).toBe(404) From 9d2f7f43625904605a5ed5d9cf3c417db5db122a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 28 Jul 2026 17:08:40 +0800 Subject: [PATCH 014/153] docs(web): describe the running dot as the chase master now renders Master replaced StateDot's ongoing ring with a pixel-art chase, so the prompt line's run-state description named an indicator that no longer exists. Same fix in the note, both READMEs, and the test name. --- .../feature/2026-07-28-web-terminal-card.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-28-web-terminal-card.md | 2 +- .../implemented/feature/2026-07-28-web-terminal-card.zh.md | 2 +- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/TerminalBlock.tsx | 2 +- packages/client/ui-primitives/tests/terminal-block.spec.tsx | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 98cbad4ffc..9739ef195a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 7dbe688b58e78a8f67fe14809b86a6bfd4f7ce36 -2026-07-28-web-terminal-card.zh.md: 1e8c847e2955605be1a55b45d84fb449ea2a46fd +2026-07-28-web-terminal-card.md: 0c10e867afe6a9cc7085206709ae94df54bc4ed0 +2026-07-28-web-terminal-card.zh.md: e680f34bc5852430e00a5970389be0280b086f3b diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 7dbe688b58..0c10e867af 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -16,7 +16,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ The component's contract: -- **Prompt line.** A run-state dot, then a shortened cwd label, then the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. The dot is `StateDot` in three of its four states: the spinning ring while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. It leads the line because the first question a reader has about a shell command is whether it is still running, and without the dot that had to be inferred from the absence of output — which a settled command producing no output also looks like. `StateDot` is `aria-hidden`, so a visually hidden text label rides beside it. +- **Prompt line.** A run-state dot, then a shortened cwd label, then the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. The dot is `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. It leads the line because the first question a reader has about a shell command is whether it is still running, and without the dot that had to be inferred from the absence of output — which a settled command producing no output also looks like. `StateDot` is `aria-hidden`, so a visually hidden text label rides beside it. - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding. - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends. - **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index 1e8c847e29..e680f34bc5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -16,7 +16,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c 该组件的契约: -- **提示符行。** 一枚运行状态点,其后是缩短的 cwd 标签,再原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。状态点是 `StateDot` 四种状态中的三种:运行期间为旋转圆环,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。它位于行首,因为读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有该状态点时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。`StateDot` 是 `aria-hidden`,因此其旁伴随一处视觉隐藏的文本标签。 +- **提示符行。** 一枚运行状态点,其后是缩短的 cwd 标签,再原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。状态点是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。它位于行首,因为读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有该状态点时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。`StateDot` 是 `aria-hidden`,因此其旁伴随一处视觉隐藏的文本标签。 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。 - **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。 diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 78281969f3..495831a377 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: b0387debbecb713b1e4af2b1497e81ddda083a51 -README.zh.md: d04a34951422c9cdd02421759a4e29672b1f7a54 +README.md: bc880333157f5cb790cbaf854b01aaf8ea6c1cc9 +README.zh.md: 6593e4518d09eaccee7e55874b1162943c5eb3bd diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b0387debbe..bc88033315 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: a prompt line (a run-state `StateDot` ahead of the shortened `cwd` label, then the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. The dot reaches three of `StateDot`'s states — the spinning ring while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries a visually hidden text label because `StateDot` is `aria-hidden`. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: a prompt line (a run-state `StateDot` ahead of the shortened `cwd` label, then the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. The dot reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries a visually hidden text label because `StateDot` is `aria-hidden`. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d04a349514..6593e4518d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签之前是一枚运行状态 `StateDot`,其后是命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。该状态点用到 `StateDot` 的三种状态——`running` 期间为旋转圆环,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它同时携带一处视觉隐藏的文本标签。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签之前是一枚运行状态 `StateDot`,其后是命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。该状态点用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它同时携带一处视觉隐藏的文本标签。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 5431f3c443..c5278dd7df 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -75,7 +75,7 @@ function statusText(exitCode: number | undefined, signal: string | undefined): s * Run-state indicator for the command, shown at the head of the prompt line so * the card states whether the command is still running without the reader * having to infer it from the presence of output. Three of {@link StateDotState}'s - * four states are reachable: the spinning ring while running (the same + * four states are reachable: the running chase (the same * indicator a running tool row's leading icon uses, so the row and its card * never disagree), green for a clean settle, red for a signal or a non-zero * exit — the same status distinction {@link statusText} draws for the pill. A diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx index fd928f48d5..486aac1197 100644 --- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -174,7 +174,7 @@ describe('TerminalBlock status pill', () => { }) describe('TerminalBlock run-state dot', () => { - it('shows the spinning ring and its running label while the command runs', () => { + it('shows the running chase and its running label while the command runs', () => { const view = render() expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' }) }) From c565022c8af85c5fd3b780c6459a6939aca8c4bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:09:43 +0800 Subject: [PATCH 015/153] fix(host): derive the picker capability union from a merge-extensible map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: the seam documented a merge-extensible union but shipped a closed alias, and the gateway schema rejected any kind beyond dialog/browse — a third backend could neither implement the seam nor be advertised. The union now derives from an augmentable DirectoryPickerCapabilities map, host.describe.directoryPicker preserves unknown wire kinds, and the browse fixture applies listDirectory's root special case so creating under '/' no longer mints a '//name' identity. --- docs/cordis-catalog/services.md | 2 +- packages/client/connection/src/client/fixture.ts | 4 +++- packages/client/connection/tests/fixture.spec.ts | 16 ++++++++++++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 6 +++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +++- packages/host/apiproxy/src/api/host.ts | 5 ++++- packages/host/apiproxy/tests/rpc-schemas.spec.ts | 4 +++- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 14 ++++++++++++-- 11 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb212207b3..397e7320d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a490f0d27d..fdc258beed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -831,7 +831,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (children === undefined) { return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) } - const target = `${parent}/${request.payload.name}` + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` if (children.includes(request.payload.name)) { return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 1b413441df..3f07922f4d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -312,6 +312,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' })) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' })) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 04b2e60ef0..698dd32201 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1603,9 +1603,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, + { + name: 'DirectoryPickerCapabilities', + declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + }, { name: 'DirectoryPickerCapability', - declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', }, { name: 'DirectoryPickerDialogCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index a73b659970..d4a3a95754 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,7 +17,9 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), - directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]), + // Open string, not a literal union: unknown kinds must survive the wire so + // a merge-added capability can advertise (the client hides the affordance). + directoryPicker: z.string(), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index abdb3c468b..3de58b7bc7 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -11,8 +11,11 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' * the host display (`host.pickDirectory`); `browse` = in-app listing/creation * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method * outside the advertised kind fails with `directory-picker-unavailable`. + * The wire preserves kinds beyond the two with methods here (a merge-added + * capability advertises before its RPCs exist); the client's documented + * default for a kind it does not recognize is to hide the picking affordance. */ -export type DirectoryPickerKind = 'dialog' | 'browse' +export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {}) /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f64883c85b..939de7677e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -211,7 +211,9 @@ describe('host domain schemas', () => { const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow() + // A kind beyond the two with methods survives the wire (merge-added + // capabilities advertise; the client hides the affordance). + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other') }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 0d5c753e92..a331a2caeb 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/README.md -README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868 -README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42 +README.md: 0332f7df067bfa79c7505be948554e814a690c6c +README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index c1a801cf72..0332f7df06 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index c352b35b70..a9a782019d 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e184b36e1..5a6040e51f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -73,8 +73,18 @@ export interface DirectoryPickerBrowseCapability { createDirectory(path: string, name: string): Promise } -/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */ -export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability +/** + * Merge-extensible registry of interaction shapes keyed by capability kind: a + * new backend declaration-merges its shape here (the entry's `kind` literal + * must equal its key) instead of editing this package. + */ +export interface DirectoryPickerCapabilities { + dialog: DirectoryPickerDialogCapability + browse: DirectoryPickerBrowseCapability +} + +/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */ +export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities] /** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed' From 006a6655ee78db607524e1c1f000273f488ff96b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:15:39 +0800 Subject: [PATCH 016/153] feat(web): in-app workspace-directory browser as the shipped picking default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Open-local-folder flow now branches on the Host's advertised picker interaction (host.describe.directoryPicker, read per menu open; unknown kinds hide the entry): dialog keeps the native-chooser flow, and browse opens the new in-app directory browser (figma Harness 802-56979) — breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone right of the crumbs, host-flagged hidden entries filtered client-side, an inline New-folder row, and Open adopting the listed directory through the existing workspace-creation error surface. Dialog copy is localized (ctx.locale, namespace 'workspace'); the plugin re-registers its entries on locale/change. apps/cli flips the composed backend from -dialog to -browse, so the picker works for remote deployments out of the box; -dialog stays a composable alternative. The workspace-management e2e drops its native picker monkey-patch and drives the real modal end-to-end via the path-edit affordance. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/cli/cordis.yml | 4 +- apps/cli/package.json | 2 +- apps/web/tests/workspace-management.e2e.ts | 53 ++-- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 5 +- packages/client/ui-workspace/README.zh.md | 5 +- packages/client/ui-workspace/package.json | 3 + .../src/client/DirectoryBrowser.module.css | 185 +++++++++++++ .../src/client/DirectoryBrowser.tsx | 246 ++++++++++++++++++ .../src/client/WorkspaceBrowser.tsx | 8 + .../src/client/WorkspacePicker.tsx | 86 ++++-- .../ui-workspace/src/client/contract/slots.ts | 33 ++- .../client/ui-workspace/src/client/index.ts | 55 +++- .../client/ui-workspace/tests/apply.spec.ts | 18 +- .../tests/directory-browser.spec.tsx | 160 ++++++++++++ .../tests/workspace-browser.spec.tsx | 4 + .../tests/workspace-picker.spec.tsx | 96 ++++++- packages/client/ui-workspace/tsconfig.json | 3 + pnpm-lock.yaml | 7 +- 22 files changed, 913 insertions(+), 72 deletions(-) create mode 100644 packages/client/ui-workspace/src/client/DirectoryBrowser.module.css create mode 100644 packages/client/ui-workspace/src/client/DirectoryBrowser.tsx create mode 100644 packages/client/ui-workspace/tests/directory-browser.spec.tsx 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 ccef0728d9..6790dafee5 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: 8d9d34e7aed4525b243380a4a90801fe59bfc213 -2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb +2026-07-28-directory-picker-capability-seam.md: 2bfb80965cec020bac0995173cf5b44ff6f14e37 +2026-07-28-directory-picker-capability-seam.zh.md: ab303e5eca8ae18413e4d2496a6fae3774630e6a 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 8d9d34e7ae..2bfb80965c 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 @@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`. +- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the in-app browser is the shipped default), the GUI branches on `describe`, and `-dialog` stays a composable alternative for host-display deployments. - The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. - A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. 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 282f3905c3..ab303e5eca 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 @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(应用内浏览器为发布默认),GUI 按 `describe` 分支,`-dialog` 作为面向宿主屏幕部署的可组合备选保留。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 - 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 1ca28ecdaa..59ca20a802 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -234,9 +234,9 @@ # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). # Directory-picking backend consumed by the gateway's host.* picker RPCs. -# Swap point: mount '-browse' instead for the in-app browser (remote-capable). +# Swap point: mount '-dialog' instead for the native OS chooser (host-display only). - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-dialog' + name: '@deepseek-ai/dsh-host-directory-picker-browse' - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' diff --git a/apps/cli/package.json b/apps/cli/package.json index 90b51e0397..c40903de87 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-dialog": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index fd18d89087..674969debe 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -30,14 +30,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car let browser: Browser let page: Page let tripwire: ReturnType - let pickedDirectory: string | null = null + + /** + * Drive the in-app browser to a directory via its path-edit affordance, + * confirm it, and wait for the adoption to settle host-side (workspace + * registered + the flow's New-Session agent up), so later test steps can't + * race the in-flight blank-session attach. + */ + async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise { + const agentsBefore = scaffold.ctx.agents.list().length + await page.getByRole('button', { name: 'Create workspace' }).click() + await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '编辑路径' }).click() + await dialog.getByLabel('编辑路径').fill(path) + await dialog.getByLabel('编辑路径').press('Enter') + await dialog.getByRole('button', { name: '打开' }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(path), + { timeout: 10_000 }, + ).not.toBeUndefined() + // First adoption births a blank Session+Agent whose workspace attach must + // settle before a test may delete the registration; the reuse path (same + // canonical cwd already has a blank session) creates no agent, so callers + // opt in only where a fresh attach is possible. + if (options.waitForAgent === true) { + await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 }) + .toBeGreaterThan(agentsBefore) + } + } beforeAll(async () => { scaffold = await launchWebScaffold({}) - scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: true, value: { path: pickedDirectory } }, - }) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) @@ -137,14 +163,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - - await expect.poll( - () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), - { timeout: 10_000 }, - ).not.toBeUndefined() + await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true }) const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -200,9 +219,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. - pickedDirectory = scaffold.workspaceCwd - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), { timeout: 10_000 }, @@ -272,9 +289,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) - pickedDirectory = oldPath - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + await openLocalFolder(oldPath) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(oldPath), { timeout: 10_000 }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..2b9868cdc4 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 -README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 +README.md: e478670facaccadd49999a5dfeaac369801036e3 +README.zh.md: 1348b59ac35aaaa103f8653dad33633bbe7792b1 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..e478670fac 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action branches on the Host's advertised picker interaction (`host.describe.directoryPicker`, read per menu open; an unknown kind hides the entry): under `dialog` it delegates to the Host's native single-directory chooser, and under `browse` it opens the in-app directory browser (figma 802-56979) — breadcrumbs rooted at a localized Home crumb, a click-to-edit path zone right of the crumbs (Enter navigates, Escape restores), host-flagged hidden entries filtered client-side, an inline New-folder row, and Open adopting the listed directory. Either way adoption goes through the object layer and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. The browser dialog's copy is localized through `ctx.locale` (namespace `workspace`), and the plugin re-registers its entries on `locale/change`. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -19,4 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. -- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. +- **Native folder selection depends on the local Host carrier** — under a `dialog` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. The shipped default composes `browse`, which has no such dependence. +- **No show-hidden toggle yet** — the Host flags hidden entries and the browser filters them unconditionally; the toggle is a deferred client-only change. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..1348b59ac3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作按 Host 广播的选择器交互形态分支(`host.describe.directoryPicker`,每次菜单打开时读取;未知 kind 隐藏该入口):在 `dialog` 下委托 Host 的原生单目录选择器,在 `browse` 下打开应用内目录浏览器(figma 802-56979)——面包屑以本地化的"主目录"crumb 为根、面包屑右侧空白区点击进入路径编辑态(Enter 导航、Escape 还原)、宿主打标的隐藏条目在客户端过滤、内联新建文件夹行、"打开"接纳当前列出的目录。两条路径的接纳都经由对象层,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。浏览器对话框的文案经 `ctx.locale` 本地化(命名空间 `workspace`),插件在 `locale/change` 时重新注册其条目。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,5 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 +- **原生文件夹选择依赖本地 Host 载体**:在 `dialog` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。已发布的默认组合为 `browse`,没有此依赖。 +- **尚无"显示隐藏目录"开关**:Host 打标隐藏条目、浏览器无条件过滤;该开关是延期的纯客户端改动。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c36486d2fd..29d1ecfa36 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -25,6 +25,7 @@ "dshClient": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-sidebar" ], @@ -39,6 +40,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", @@ -47,6 +49,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css new file mode 100644 index 0000000000..b936148cd8 --- /dev/null +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -0,0 +1,185 @@ +/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask, + * card, and title row; this module widens the card and rebuilds the figma + * header/footer separators with bleed margins inside the 24px content column. */ + +.dialog { + width: min(600px, 100%); +} + +/* Breadcrumb bar sits visually inside the header block: bleed to the card + * edges, close the header's 12px bottom pad, draw the l3 separator. */ +.crumbBar { + display: flex; + align-items: center; + gap: 4px; + min-height: 32px; + margin: -12px -24px 0; + padding: 0 24px 12px; + border-bottom: 1px solid var(--dsw-alias-border-l3); +} + +.crumbSeat { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + min-width: 0; +} + +.crumb { + border: none; + background: transparent; + padding: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.crumb:hover { + color: var(--dsw-alias-label-primary); +} + +.crumbChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +/* The empty remainder of the bar: invisible, but a real click target that + * flips the bar into path-edit mode. */ +.crumbEditZone { + flex: 1 1 0; + min-width: 34px; + align-self: stretch; + border: none; + background: transparent; + cursor: text; +} + +.pathInput { + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 28px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + outline: none; + background: transparent; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */ +.level { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: -4px; + max-height: 320px; + overflow-y: auto; +} + +.row { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + flex: none; + padding: 4px; + border: none; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.rowIcon { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.rowName { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.rowChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.folderRow { + cursor: default; +} + +.folderInput { + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 24px; + padding: 0 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + outline: none; + background: transparent; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.folderInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.status, +.error { + padding: 4px; + font-size: 12px; + line-height: 18px; +} + +.status { + color: var(--dsw-alias-label-secondary); +} + +.error { + color: var(--dsw-alias-state-error-primary); +} + +/* Footer: the l3 separator above the action row, New-folder pinned left + * (bleeds across the card; 12px stays below, matching the figma card pad). */ +.footerBar { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% + 48px); + margin: 0 -24px -12px; + padding: 12px 24px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.footerGap { + flex: 1 1 0; +} + +.footerAction { + min-width: 72px; +} diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx new file mode 100644 index 0000000000..0301d9e27f --- /dev/null +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -0,0 +1,246 @@ +/** + * The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb + * header with a click-to-edit path zone, one navigable directory level, an + * inline New-folder row, and the Cancel/Open footer. 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). + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconChevronRightOutline14, IconFolderClose16, 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' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' +import css from './DirectoryBrowser.module.css' + +/** Owner-supplied browser props: browse calls, pick semantics, and copy. */ +export interface DirectoryBrowserProps { + /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ + open: boolean + /** List one directory level (absent path = the Host home directory). */ + listDirectory: (path?: string) => Promise + /** Create one child directory under the listed level. */ + createDirectory: (path: string, name: string) => Promise + /** The operator confirmed the currently listed directory. */ + onOpen: (path: string) => void + /** Close without picking (mask, Escape, Cancel). */ + onClose: () => void + /** The owner's confirm is in flight: Open disables, the level freezes. */ + busy: boolean + /** Localized copy. */ + t: Translate +} + +/** Failure text: the Host business message when typed, else the throw's text. */ +function failureText(error: unknown): string { + if (error instanceof DirectoryBrowseError) return error.rpcError.message + return error instanceof Error ? error.message : String(error) +} + +/** + * Breadcrumb rows for display: inside the home subtree the chain starts at a + * localized Home crumb; outside it the full ancestry shows, the root labeled + * by its own path. + */ +function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { + const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) + if (homeIndex === -1) return listing.crumbs + const tail = listing.crumbs.slice(homeIndex + 1) + return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] +} + +/** + * Render the directory-browser dialog. + * @param props - owner-controlled browser props. + * @returns the dialog element (null while closed, via Modal). + */ +export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) { + const [listing, setListing] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + // Path-edit state: null = breadcrumb mode; a string = the draft being typed. + const [pathDraft, setPathDraft] = useState(null) + // New-folder state: null = no inline row; a string = the name being typed. + const [folderDraft, setFolderDraft] = useState(null) + const [creatingFolder, setCreatingFolder] = useState(false) + const requestSeq = useRef(0) + + const navigate = useCallback((path?: string) => { + const seq = ++requestSeq.current + setLoading(true) + setError(null) + listDirectory(path).then((next) => { + if (seq !== requestSeq.current) return + setListing(next) + setLoading(false) + setPathDraft(null) + setFolderDraft(null) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) + }, [listDirectory]) + + // Every open starts fresh at the Host home directory; closing invalidates + // any in-flight response so a late arrival cannot repopulate a closed dialog. + useEffect(() => { + if (open) { + setListing(null) + navigate() + return + } + requestSeq.current += 1 + setError(null) + setPathDraft(null) + setFolderDraft(null) + }, [open, navigate]) + + const confirmFolder = (): void => { + if (listing === null || folderDraft === null || creatingFolder) return + const name = folderDraft.trim() + if (name === '') return + setCreatingFolder(true) + setError(null) + createDirectory(listing.path, name).then(() => { + setCreatingFolder(false) + setFolderDraft(null) + navigate(listing.path) + }, (reason: unknown) => { + setCreatingFolder(false) + setError(failureText(reason)) + }) + } + + // After the hooks: a closed dialog renders nothing and evaluates no copy. + if (!open) return null + + const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home')) + + return ( + + + + + +
+ )} + > +
+ {pathDraft === null + ? ( + <> + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + + ))} + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+ {folderDraft !== null && listing !== null && ( +
+ + { setFolderDraft(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmFolder() + } + if (event.key === 'Escape') { + event.stopPropagation() + setFolderDraft(null) + } + }} + /> +
+ )} + {listing?.entries.filter(entry => !entry.hidden).map(entry => ( + + ))} + {loading &&
{t('browser.loading')}
} + {error !== null &&
{error}
} +
+ + ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..f15c8090fe 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,7 +253,11 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, + directoryPickerKind, pickDirectory, + listDirectory, + createDirectory, + t, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -371,7 +375,11 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} + directoryPickerKind={directoryPickerKind} pickDirectory={pickDirectory} + listDirectory={listDirectory} + createDirectory={createDirectory} + t={t} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..fb7edd815a 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,24 +5,25 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerProps } from './contract/slots.ts' +import type { DirectoryPickingInjected, WorkspacePickerProps } from './contract/slots.ts' +import { DirectoryBrowser } from './DirectoryBrowser.tsx' import css from './WorkspacePicker.module.css' const OPEN_LOCAL_FOLDER = '::open-local-folder' const CREATE_NEW = '::create-new' -type ModalKind = 'create' | 'folder-error' | null +type ModalKind = 'create' | 'folder-error' | 'browse' | null /** Core flow props: the owner supplies popover control and pick semantics. */ -export interface WorkspaceCreateFlowProps { +export interface WorkspaceCreateFlowProps extends DirectoryPickingInjected { /** Popover visibility (anchor button toggle state, owner-local). */ open: boolean /** The anchor button element — the popover's placement anchor. */ @@ -31,8 +32,6 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Open the Host's native single-directory picker. */ - pickDirectory: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -49,7 +48,11 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, + directoryPickerKind, pickDirectory, + listDirectory, + createDirectory, + t, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -65,6 +68,21 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) + // The Host's picker interaction: read while the menu is open; 'unknown' + // (fetch failure or an unadvertised kind) hides the local-folder entry — + // the merge-extensible union's documented default. + const [pickerKind, setPickerKind] = useState(null) + useEffect(() => { + if (!open) return + let stale = false + directoryPickerKind().then( + // The wire type is the closed two-kind union today; a fetch failure is + // the reachable 'unknown' arm (an unadvertisable host hides the entry). + (kind) => { if (!stale) setPickerKind(kind) }, + () => { if (!stale) setPickerKind('unknown') }, + ) + return () => { stale = true } + }, [open, directoryPickerKind]) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) @@ -77,17 +95,40 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + ...(pickerKind === 'unknown' ? [] : [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder || pickerKind === null }, + ]), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] const closeModal = (): void => { - if (creating) return + if (creating || pickingFolder) return setModalKind(null) setModalError(null) } + /** Adopt a chosen directory as a Workspace; failures land in the folder-error dialog. */ + const adoptDirectory = (path: string): Promise => + createWorkspace({ path }).then((workspace) => { + setModalKind(null) + onPick(workspace.workspaceId) + }).catch((reason: unknown) => { + setFolderConflict( + reason instanceof WorkspaceCreateError + && reason.rpcError.code === 'workspace-name-conflict', + ) + setModalError(reason instanceof Error ? reason.message : String(reason)) + setModalKind('folder-error') + }) + const openLocalFolder = (): void => { + if (pickerKind === 'browse') { + onClose() + setModalError(null) + setFolderConflict(false) + setModalKind('browse') + return + } onClose() setModalKind(null) setModalError(null) @@ -95,13 +136,8 @@ export function WorkspaceCreateFlow({ setPickingFolder(true) void pickDirectory().then(async (path) => { if (path === null) return - const workspace = await createWorkspace({ path }) - onPick(workspace.workspaceId) + await adoptDirectory(path) }).catch((reason: unknown) => { - setFolderConflict( - reason instanceof WorkspaceCreateError - && reason.rpcError.code === 'workspace-name-conflict', - ) setModalError(reason instanceof Error ? reason.message : String(reason)) setModalKind('folder-error') }).finally(() => { setPickingFolder(false) }) @@ -155,6 +191,18 @@ export function WorkspaceCreateFlow({ getAnchorRect={getAnchorRect} /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} + { + setPickingFolder(true) + void adoptDirectory(path).finally(() => { setPickingFolder(false) }) + }} + /> diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..b604c3ece0 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,15 +13,38 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { + DirectoryListing, DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { Translate } from '@deepseek-ai/dsh-client-locale/client' import type { createWorkspaceViewStore } from '../stores.ts' +/** + * Directory-picking share both registrations consume: the Host's composed + * picker interaction decides which calls the flow drives (`dialog` opens the + * native chooser through `pickDirectory`; `browse` drives the in-app browser + * through `listDirectory`/`createDirectory`; an unknown kind hides the + * local-folder entry — the merge-extensible union's documented default). + */ +export type DirectoryPickingInjected = { + /** The Host's advertised picker interaction, read per flow open. */ + directoryPickerKind: () => Promise + /** Ask the local Host to open its native single-directory picker (`dialog`). */ + pickDirectory: () => Promise + /** List one directory level with breadcrumb ancestry (`browse`). */ + listDirectory: (path?: string) => Promise + /** Create one child directory under an existing parent (`browse`). */ + createDirectory: (path: string, name: string) => Promise + /** Localized picker copy (this package's locale namespace). */ + t: Translate +} + /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = { +export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** * Start a New Session in a Workspace: reuse-or-create its blank session * and open it; with no workspace, clear the selection into the New Session @@ -42,8 +65,6 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -57,11 +78,9 @@ export type WorkspaceBrowserProps = * callback; this callback creates only the real Host Workspace. A type alias * supplies the implicit index signature required by the registry. */ -export type WorkspacePickerInjected = { +export type WorkspacePickerInjected = DirectoryPickingInjected & { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..4c400109f5 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -7,15 +7,19 @@ * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import type { DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' import { createWorkspaceViewStore } from './stores.ts' import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { + DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' +/** Locale namespace for the picker surfaces (dictionaries registered in apply). */ +const LOCALE_NS = 'workspace' + /** * Required services (cordis fiber inject). The target slots are declared by * the ui-sidebar / ui-conversation applies, whose activation order relative @@ -24,7 +28,7 @@ export type { * provides a waitable service. apply therefore registers via * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces', 'locale'] /** * Register the browser and picker once their slot declarations are on the @@ -33,6 +37,39 @@ export const inject = ['slots', 'sessions', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => { + const disposers = [ + ctx.locale.register(LOCALE_NS, 'zh', { + 'browser.title': '选择工作区目录', + 'browser.home': '主目录', + 'browser.newFolder': '新建文件夹', + 'browser.folderName': '文件夹名称', + 'browser.cancel': '取消', + 'browser.open': '打开', + 'browser.editPath': '编辑路径', + 'browser.loading': '加载中…', + }), + ctx.locale.register(LOCALE_NS, 'en', { + 'browser.title': 'Select Workspace Directory', + 'browser.home': 'Home', + 'browser.newFolder': 'New folder', + 'browser.folderName': 'Folder name', + 'browser.cancel': 'Cancel', + 'browser.open': 'Open', + 'browser.editPath': 'Edit path', + 'browser.loading': 'Loading…', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-workspace: picker dictionaries') + + const picking = (): DirectoryPickingInjected => ({ + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), + pickDirectory: () => ctx.workspaces.pickDirectory(), + listDirectory: path => ctx.workspaces.listDirectory(path), + createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), + t: ctx.locale.bind(LOCALE_NS), + }) const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session rides // the runtime's shared action (recent-Workspace projection inside). @@ -44,11 +81,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + ...picking(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), + ...picking(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register @@ -83,7 +120,17 @@ export function apply(ctx: ClientContext): void { const unsubscribers = registrations.map(entry => ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) for (const entry of registrations) tryRegister(entry) + // Language switch: re-register both entries so open surfaces re-render + // with the other dictionary (the bound t keeps a stable identity). + const offLocale = ctx.on('locale/change', () => { + for (const [name, dispose] of disposers) { + dispose() + disposers.delete(name) + } + for (const entry of registrations) tryRegister(entry) + }) return () => { + offLocale() for (const unsubscribe of unsubscribers) unsubscribe() for (const dispose of disposers.values()) dispose() } diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..9e29117916 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,16 +15,28 @@ async function bench() { title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) const pickDirectory = vi.fn(async () => '/tmp/picked') + const directoryPickerKind = vi.fn(async () => 'browse' as const) + const listDirectory = vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })) + const createDirectory = vi.fn(async () => '/home/u/new') const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, pickDirectory, startSession, rename, insertSessionBefore, + create, pickDirectory, directoryPickerKind, listDirectory, createDirectory, + startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } + // Structural locale fake: register/bind are the only members apply touches. + const localeRegister = vi.fn(() => () => {}) + const boundT = (key: string): string => key + ctx.provide('locale', { register: localeRegister, bind: () => boundT } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, + directoryPickerKind, listDirectory, createDirectory, localeRegister, boundT, + startSession, rename, insertSessionBefore, open, clear, + } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -37,7 +49,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale']) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx new file mode 100644 index 0000000000..5b58f0667e --- /dev/null +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -0,0 +1,160 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' +import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' +import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' + +afterEach(cleanup) + +const HOME = '/home/u' + +/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ +function listingFor(path?: string): DirectoryListing { + const target = path ?? HOME + const tree: Record = { + [HOME]: { + path: HOME, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + ], + entries: [ + { name: '.config', path: `${HOME}/.config`, hidden: true }, + { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + ], + }, + [`${HOME}/Documents`]: { + path: `${HOME}/Documents`, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + ], + entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, hidden: false }], + }, + } + const found = tree[target] + if (found === undefined) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } }) + } + return found +} + +function mount(overrides: Partial[0]> = {}) { + const listDirectory = vi.fn(async (path?: string) => listingFor(path)) + const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`) + const onOpen = vi.fn() + const onClose = vi.fn() + const props = { + open: true, + listDirectory, + createDirectory, + onOpen, + onClose, + busy: false, + t: (key: string) => key, + ...overrides, + } + const view = render() + return { view, props, listDirectory, createDirectory, onOpen, onClose } +} + +describe('DirectoryBrowser', () => { + it('opens at the Host home, hides hidden entries, and roots the crumbs at Home', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(b.listDirectory).toHaveBeenCalledWith(undefined) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + expect(screen.queryByText('.config')).toBeNull() + // Inside the home subtree the chain collapses to a localized Home crumb. + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '/' })).toBeNull() + }) + + it('enters a row on click and jumps back through a crumb', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + }) + + it('edits the path from the crumb bar: Enter navigates, Escape restores', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe(HOME) + fireEvent.change(input, { target: { value: `${HOME}/Documents` } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // Escape leaves an opened edit without navigating. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(screen.getByRole('listitem').textContent).toBe('harness') + }) + + it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/nope' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') }) + expect(screen.getByLabelText('browser.editPath')).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('creates a folder inline and refreshes the level; failures land as alerts', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.newFolder') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') }) + // The level reloads after creation (initial + post-create). + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) }) + + b.createDirectory.mockRejectedValueOnce( + new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const retry = screen.getByLabelText('browser.newFolder') + fireEvent.change(retry, { target: { value: 'x' } }) + fireEvent.keyDown(retry, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + }) + + it('confirms the listed directory through Open, closes through Cancel, and freezes while busy', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + expect(b.onOpen).toHaveBeenCalledWith(HOME) + fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' })) + expect(b.onClose).toHaveBeenCalled() + + const busy = mount({ busy: true }) + await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) + expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) + }) + + it('starts back at home on reopen', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + b.view.rerender() + b.view.rerender() + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) + }) +}) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..6919571052 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -59,7 +59,11 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), + directoryPickerKind: vi.fn(async () => 'dialog' as const), pickDirectory: vi.fn(async () => null), + listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + createDirectory: vi.fn(async () => '/home/u/new'), + t: (key: string) => key, ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..d400cf0435 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -5,6 +5,7 @@ import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryPickingInjected } from '../src/client/contract/slots.ts' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -35,14 +36,34 @@ function anchor(): { current: HTMLElement } { return { current: element } } +/** Minimal picking share for direct renders (kind resolves to dialog). */ +function pickingShare(): DirectoryPickingInjected { + return { + directoryPickerKind: vi.fn(async () => 'dialog' as const), + pickDirectory: vi.fn(async () => null), + listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + createDirectory: vi.fn(async () => '/home/u/new'), + t: (key: string) => key, + } +} + function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), + picking: Partial = {}, ) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() + const share: DirectoryPickingInjected = { + directoryPickerKind: vi.fn(async () => 'dialog' as const), + pickDirectory, + listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + createDirectory: vi.fn(async () => '/home/u/new'), + t: key => key, + ...picking, + } const renderPicker = (nextItems: readonly WorkspaceView[]) => ( ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, pickDirectory, share, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } @@ -68,6 +89,16 @@ function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): vo fireEvent.click(screen.getByRole('menuitem', { name })) } +/** The local-folder entry disables until the Host's picker kind resolves. */ +async function chooseLocalFolder(): Promise { + await waitFor(() => { + const item = screen.getByRole('menuitem', { name: 'Open local folder…' }) + expect(item).not.toHaveProperty('ariaDisabled', 'true') + expect(item.getAttribute('aria-disabled')).not.toBe('true') + }) + chooseItem('Open local folder…') +} + describe('WorkspacePicker', () => { it('lists real Workspaces from useWorkspaces and forwards a selected id', () => { const b = mount() @@ -92,7 +123,7 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const pickDirectory = vi.fn(async () => '/tmp/project') const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseLocalFolder() expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) @@ -101,7 +132,7 @@ describe('WorkspacePicker', () => { it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() @@ -118,7 +149,7 @@ describe('WorkspacePicker', () => { }) }) const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) @@ -132,7 +163,7 @@ describe('WorkspacePicker', () => { let resolve!: (path: string | null) => void const pending = new Promise((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) - chooseItem('Open local folder…') + await chooseLocalFolder() expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) @@ -142,7 +173,7 @@ describe('WorkspacePicker', () => { it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - chooseItem('Open local folder…') + await chooseLocalFolder() await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) @@ -212,11 +243,58 @@ describe('WorkspacePicker', () => { expect(b.onPick).not.toHaveBeenCalled() }) + it('opens the in-app browser under the browse capability and adopts the confirmed directory', async () => { + const created = { ...workspace('adopted'), path: '/home/u', title: 'u' } + const createWorkspace = vi.fn(async () => created) + const b = mount([], createWorkspace, vi.fn(), { + directoryPickerKind: vi.fn(async () => 'browse' as const), + }) + await chooseLocalFolder() + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() }) + // The dialog listed home; Open adopts the listed directory. + await waitFor(() => { expect(b.share.listDirectory).toHaveBeenCalled() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/home/u' }) }) + await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) + expect(b.pickDirectory).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('routes an adoption conflict from the browser into the folder-error dialog, and Choose again reopens the browser', async () => { + const createWorkspace = vi.fn(async () => { + throw new WorkspaceCreateError({ + code: 'workspace-name-conflict', message: 'u already exists', details: { name: 'u' }, + }) + }) + const b = mount([], createWorkspace, vi.fn(), { + directoryPickerKind: vi.fn(async () => 'browse' as const), + }) + await chooseLocalFolder() + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() + }) + fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('hides the local-folder entry when the picker kind is unknown', async () => { + mount([], vi.fn(), vi.fn(), { + directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }), + }) + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy() + }) + it('waits to show its menu until an optional anchor is available', () => { render( , ) expect(screen.queryByRole('menu')).toBeNull() @@ -229,7 +307,7 @@ describe('WorkspacePicker', () => { render( , ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index a2679cccb4..76babb3fea 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a47a1ea140..b3a1b5fa9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-dialog': + '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ - version: link:../../packages/host/directory-picker-dialog + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -1391,6 +1391,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime From 2b64341bc0d7553c87dbbf5c39d92a6b3eee9f6d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:33:19 +0800 Subject: [PATCH 017/153] fix(web): align the directory browser with the figma frame and merge the seam tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog now owns the figma structure through a new headless Modal mode (mask/card/Escape stay shared): header block with the title and crumbs 8px apart above the l3 separator (no close chrome — the figma frame has none), 16px to the level, and the 12px card bottom. The picker-kind narrowing returns for the merged open describe kind — an unrecognized advertised kind hides the local-folder entry, now covered alongside the stale-navigation failure arm and the unmount races. --- docs/module-graph.md | 11 +- packages/client/ui-primitives/src/Modal.tsx | 38 +++-- .../src/client/DirectoryBrowser.module.css | 52 ++++--- .../src/client/DirectoryBrowser.tsx | 143 +++++++++--------- .../src/client/WorkspacePicker.tsx | 7 +- .../tests/directory-browser.spec.tsx | 82 ++++++++++ .../tests/workspace-picker.spec.tsx | 52 +++++++ 7 files changed, 276 insertions(+), 109 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 03f4bee072..31217705ba 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -292,10 +292,6 @@ flowchart TD pkg_client_ui_slash --> pkg_client_runtime pkg_client_ui_slash --> pkg_client_ui_slots pkg_client_ui_slash --> pkg_invariants - pkg_client_ui_workspace --> pkg_client_runtime - pkg_client_ui_workspace --> pkg_client_ui_primitives - pkg_client_ui_workspace --> pkg_client_ui_slots - pkg_client_ui_workspace --> pkg_invariants pkg_helper --> pkg_brand pkg_helper --> pkg_invariants pkg_helper --> pkg_subprocess @@ -350,6 +346,11 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_client_ui_workspace --> pkg_client_locale + pkg_client_ui_workspace --> pkg_client_runtime + pkg_client_ui_workspace --> pkg_client_ui_primitives + pkg_client_ui_workspace --> pkg_client_ui_slots + pkg_client_ui_workspace --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -944,7 +945,6 @@ flowchart TD | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | @@ -961,6 +961,7 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 820ff3d7a3..ef790c8b6a 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -12,13 +12,16 @@ import css from './Modal.module.css' * Render a centered modal over a blurred page mask. * @param props.open - whether the dialog is showing. * @param props.onClose - Escape or mask click. - * @param props.title - dialog heading. + * @param props.title - dialog heading (aria-label in every mode). * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.headless - render children directly in the card (no default + * header/close/body chrome) for dialogs whose figma frame owns its own + * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className }: { +export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { open: boolean onClose: () => void title: string @@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + headless?: boolean }) { useEffect(() => { if (!open) return @@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla aria-modal="true" aria-label={title} > -
-
-

{title}

- -
- {description !== undefined && description !== '' && ( -

{description}

+ {headless + ? children + : ( + <> +
+
+

{title}

+ +
+ {description !== undefined && description !== '' && ( +

{description}

+ )} + {children !== undefined &&
{children}
} +
+ {footer !== undefined &&
{footer}
} + )} - {children !== undefined &&
{children}
} -
- {footer !== undefined &&
{footer}
}
) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css index b936148cd8..14f3e4f2ca 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -1,21 +1,40 @@ -/* Directory-browser dialog (figma 802-56979). The shared Modal owns the mask, - * card, and title row; this module widens the card and rebuilds the figma - * header/footer separators with bleed margins inside the 24px content column. */ +/* Directory-browser dialog (figma 802-56979). The shared Modal renders + * headless here — mask, card, Escape only — and this module owns the figma + * frame exactly: header (title + crumbs, l3 separator), one directory level, + * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */ -.dialog { +/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */ +.dialog.dialog { width: min(600px, 100%); + padding: 0 0 12px; + gap: 16px; +} + +/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 22px 14px 12px 24px; + border-bottom: 1px solid var(--dsw-alias-border-l3); +} + +.title { + display: flex; + align-items: flex-end; + min-height: 28px; + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 510; + color: var(--dsw-alias-label-primary); } -/* Breadcrumb bar sits visually inside the header block: bleed to the card - * edges, close the header's 12px bottom pad, draw the l3 separator. */ .crumbBar { display: flex; align-items: center; gap: 4px; - min-height: 32px; - margin: -12px -24px 0; - padding: 0 24px 12px; - border-bottom: 1px solid var(--dsw-alias-border-l3); + min-height: 20px; } .crumbSeat { @@ -65,7 +84,7 @@ box-sizing: border-box; flex: 1 1 0; min-width: 0; - height: 28px; + height: 24px; padding: 0 8px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; @@ -76,12 +95,12 @@ color: var(--dsw-alias-label-primary); } -/* One directory level: 28px rows, r6, folder icon + name + enter chevron. */ +/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */ .level { display: flex; flex-direction: column; gap: 2px; - margin-top: -4px; + padding: 16px 24px 0; max-height: 320px; overflow-y: auto; } @@ -164,15 +183,12 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: the l3 separator above the action row, New-folder pinned left - * (bleeds across the card; 12px stays below, matching the figma card pad). */ +/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */ .footerBar { display: flex; align-items: center; gap: 8px; - width: calc(100% + 48px); - margin: 0 -24px -12px; - padding: 12px 24px; + padding: 12px 24px 0; border-top: 1px solid var(--dsw-alias-border-l3); } diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 0301d9e27f..9454693969 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -100,6 +100,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [open, navigate]) const confirmFolder = (): void => { + /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */ if (listing === null || folderDraft === null || creatingFolder) return const name = folderDraft.trim() if (name === '') return @@ -126,78 +127,61 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose={onClose} title={t('browser.title')} className={clsx(css.dialog)} - footer={( -
- - - - -
- )} + headless > -
- {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. */} +
{folderDraft !== null && listing !== null && ( @@ -241,6 +225,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
+
+ + + + +
) } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index fb7edd815a..3cacbe44b7 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -76,9 +76,10 @@ export function WorkspaceCreateFlow({ if (!open) return let stale = false directoryPickerKind().then( - // The wire type is the closed two-kind union today; a fetch failure is - // the reachable 'unknown' arm (an unadvertisable host hides the entry). - (kind) => { if (!stale) setPickerKind(kind) }, + // The wire kind is an open string (a merge-added capability advertises + // before this client knows it): anything but the two known kinds hides + // the entry, as does a fetch failure. + (kind) => { if (!stale) setPickerKind(kind === 'dialog' || kind === 'browse' ? kind : 'unknown') }, () => { if (!stale) setPickerKind('unknown') }, ) return () => { stale = true } diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx index 5b58f0667e..a78527d5f1 100644 --- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -147,6 +147,88 @@ describe('DirectoryBrowser', () => { expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) }) + it('renders the full ancestry when the listing sits outside the home subtree', async () => { + const outside: DirectoryListing = { + path: '/srv/data', + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + { name: 'data', path: '/srv/data', hidden: false }, + ], + entries: [], + } + mount({ listDirectory: vi.fn(async () => outside) }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() }) + expect(screen.getByRole('button', { name: '/' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull() + }) + + it('folds non-typed failures into readable text (Error message, String otherwise)', async () => { + const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') }) + b.view.rerender() + const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) }) + await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') }) + expect(raw.onOpen).not.toHaveBeenCalled() + }) + + it('cancels the inline folder row with Escape and ignores a blank name', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.newFolder') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(b.createDirectory).not.toHaveBeenCalled() + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByLabelText('browser.newFolder')).toBeNull() + }) + + it('ignores a blank path draft on Enter', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Only the initial home listing ran; the blank draft navigated nowhere. + expect(b.listDirectory).toHaveBeenCalledTimes(1) + }) + + it('drops a stale listing that resolves after a newer navigation', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it. + let resolveSlow!: (value: DirectoryListing) => void + const slow = new Promise((settle) => { resolveSlow = settle }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(screen.getByRole('listitem')) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + resolveSlow(listingFor(`${HOME}/Documents`)) + await new Promise(settle => setTimeout(settle, 0)) + // The stale Documents listing did not clobber the newer Home level. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('drops a stale failure that rejects after a newer navigation', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + let rejectSlow!: (reason: unknown) => void + const slow = new Promise((_settle, fail) => { rejectSlow = fail }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(screen.getByRole('listitem')) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) + rejectSlow(new Error('too late to matter')) + await new Promise(settle => setTimeout(settle, 0)) + // The superseded failure surfaces no alert over the newer level. + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('starts back at home on reopen', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d400cf0435..0c634fbd60 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -280,6 +280,58 @@ describe('WorkspacePicker', () => { expect(b.onPick).not.toHaveBeenCalled() }) + it('drops a picker-kind failure that lands after unmount', async () => { + let rejectKind!: (reason: unknown) => void + const pending = new Promise<'dialog'>((_settle, fail) => { rejectKind = fail }) + const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) }) + b.view.unmount() + await act(async () => { + rejectKind(new Error('gone')) + await pending.catch(() => {}) + }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('drops a picker-kind resolution that lands after unmount', async () => { + let resolveKind!: (kind: 'dialog') => void + const pending = new Promise<'dialog'>((settle) => { resolveKind = settle }) + const b = mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(() => pending) }) + b.view.unmount() + await act(async () => { + resolveKind('dialog') + await pending + }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('reports a browse adoption failure thrown as a plain string', async () => { + const b = mount([], vi.fn(async () => { throw 'disk detached' }), vi.fn(), { + directoryPickerKind: vi.fn(async () => 'browse' as const), + }) + await chooseLocalFolder() + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('disk detached') }) + expect(b.onPick).not.toHaveBeenCalled() + }) + + it('reports a native picker Error by its message', async () => { + const b = mount([], vi.fn(), vi.fn(async () => { throw new Error('no chooser installed') })) + await chooseLocalFolder() + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('no chooser installed') }) + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + + it('hides the local-folder entry for an unrecognized advertised kind', async () => { + mount([], vi.fn(), vi.fn(), { + directoryPickerKind: vi.fn(async () => 'electron-native'), + }) + await waitFor(() => { + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy() + }) + it('hides the local-folder entry when the picker kind is unknown', async () => { mount([], vi.fn(), vi.fn(), { directoryPickerKind: vi.fn(async () => { throw new Error('unreachable host') }), From cd7aa3c7d879d1d65df1dafc3ad2a5688f2252f2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:39:15 +0800 Subject: [PATCH 018/153] fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 2. The workspace UI never consulted the advertised directoryPicker kind: under a browse (or merge-added) backend it still rendered 'Open local folder…' and called pickDirectory(), which the host answers with directory-picker-unavailable. The create flow now reads directoryPickerKind() per menu open and renders the dialog affordance only under 'dialog' — browse (until its in-app browser UI lands) and unknown kinds hide the entry, realizing the seam's documented default; a keyless workspace-flow snapshot pins the hidden entry over the browse fixture. The browse backend also resolved wire paths, silently rebasing '' or relative parents under the host process cwd; both primitives now reject non-absolute explicit paths with their business codes, and the seam JSDoc carries the contract. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 14 ++++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 29 +++++++- .../ui-workspace/src/client/contract/slots.ts | 6 +- .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 9 ++- .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 66 ++++++++++++++----- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 11 +++- .../tests/service.spec.ts | 13 ++++ packages/host/directory-picker/src/index.ts | 6 +- 20 files changed, 146 insertions(+), 37 deletions(-) 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 ccef0728d9..c13d5e7a1c 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: 8d9d34e7aed4525b243380a4a90801fe59bfc213 -2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb +2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d +2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068 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 8d9d34e7ae..a30675f2d8 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 @@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`. +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. - The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. - A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. 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 282f3905c3..5560aba074 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 @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 - 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..715be624e0 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -174,6 +174,20 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) +it('hides the Open-local-folder entry under the fixture host\'s browse picker capability', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + const menu = await screen.findByRole('menu') + // Flush the advertised-kind read (fixture describe resolves in microtasks): + // the fixture serves `browse`, whose in-app UI is not wired yet, so the + // dialog affordance must not render — only the create action remains. + await act(async () => {}) + expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) + .toEqual(['Create a new workspace']) +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..35cd3c3702 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 -README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 +README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81 +README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..58aaf56e19 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..7c7b33e0ea 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..bf3a41522e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -254,6 +254,7 @@ export function WorkspaceBrowser({ insertSessionBefore, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -372,6 +373,7 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..8cef0af8f9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,13 +5,13 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Open the Host's native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({ useWorkspaces, createWorkspace, pickDirectory, + directoryPickerKind, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({ const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) + // The advertised interaction gates the picking affordance: 'dialog' is the + // only kind pickDirectory() can serve, so its entry renders under that kind + // alone; 'browse' (until the in-app browser UI lands) and unknown kinds + // hide the entry, the seam's documented unknown-kind default. Re-read per + // flow open — no cache to go stale across reconnects. + const [dialogPicker, setDialogPicker] = useState(false) + useEffect(() => { + if (!open) return + void directoryPickerKind() + .then((kind) => { setDialogPicker(kind === 'dialog') }) + // A failed describe hides the entry too: the same Host that cannot + // answer describe cannot serve pickDirectory. (Post-unmount settlement + // is safe: React 18 no-ops setState on unmounted components.) + .catch(() => { setDialogPicker(false) }) + }, [open, directoryPickerKind]) + const items: MenuEntry[] = [ ...workspaces.map(workspace => ({ id: workspace.workspaceId, @@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + ...(dialogPicker + ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] + : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] @@ -229,6 +250,7 @@ export function WorkspacePicker({ onClose, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspacePickerProps) { return ( diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..c20955b567 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { createWorkspaceViewStore } from '../stores.ts' /** @@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -62,6 +64,8 @@ export type WorkspacePickerInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..6b84680fbc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void { }, createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..f9a5884206 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,16 +15,17 @@ async function bench() { title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) const pickDirectory = vi.fn(async () => '/tmp/picked') + const directoryPickerKind = vi.fn(async () => 'dialog' as const) const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, pickDirectory, startSession, rename, insertSessionBefore, + create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -75,12 +76,16 @@ describe('ui-workspace apply', () => { expect(b.create).toHaveBeenCalledWith({ name: 'project' }) await browser.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledOnce() + await browser.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) await picker.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledTimes(2) + await picker.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledTimes(2) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..779ad0b37a 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -60,6 +60,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), pickDirectory: vi.fn(async () => null), + directoryPickerKind: vi.fn(async () => 'dialog' as const), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..c7f8fd9765 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -39,6 +39,7 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), + directoryPickerKind = vi.fn(async () => 'dialog'), ) { const onPick = vi.fn() const onClose = vi.fn() @@ -53,19 +54,22 @@ function mount( onClose={onClose} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} /> ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { - fireEvent.click(screen.getByRole('menuitem', { name })) +// findByRole, not getByRole: the folder entry renders only after the advertised +// picker kind resolves, one microtask after the menu opens. +async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise { + fireEvent.click(await screen.findByRole('menuitem', { name })) } describe('WorkspacePicker', () => { @@ -79,7 +83,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -92,7 +96,7 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const pickDirectory = vi.fn(async () => '/tmp/project') const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) @@ -101,7 +105,7 @@ describe('WorkspacePicker', () => { it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() @@ -118,7 +122,7 @@ describe('WorkspacePicker', () => { }) }) const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) @@ -132,7 +136,7 @@ describe('WorkspacePicker', () => { let resolve!: (path: string | null) => void const pending = new Promise((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) @@ -142,23 +146,23 @@ describe('WorkspacePicker', () => { it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) expect(b.createWorkspace).not.toHaveBeenCalled() }) - it('closes a creation modal when the user cancels', () => { + it('closes a creation modal when the user cancels', async () => { mount([]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) - it('blocks a create-new name already present in the Workspace list', () => { + it('blocks a create-new name already present in the Workspace list', async () => { const b = mount([workspace('alpha', 'Alpha')]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('button', { name: 'Create workspace' }).disabled).toBe(true) @@ -171,7 +175,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -187,7 +191,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -204,7 +208,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') @@ -217,6 +221,7 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -230,8 +235,37 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) + + it('hides the folder affordance unless the Host advertises the dialog interaction', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse')) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('hides the folder affordance when the Host cannot answer describe', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => { + throw new Error('host unreachable') + })) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('does not read the picker kind while the flow is closed', () => { + const directoryPickerKind = vi.fn(async () => 'dialog') + render( + , + ) + expect(directoryPickerKind).not.toHaveBeenCalled() + }) }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index fea6c71d41..916db3c321 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: f86f74acf4922d490b23c033a281436f1a428f13 -README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92 +README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a +README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index f86f74acf4..81357269e1 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -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. 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). +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 a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 0d240630a8..06a7f7651b 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable`/`directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 5f2a9429c0..83a3f6762e 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -11,7 +11,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string): Promise { const home = homedir() + // The seam contract takes absolute paths only; resolve() would silently + // rebase a relative or empty wire value under the host process cwd. + if (path !== undefined && !isAbsolute(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`) + } const target = resolve(path ?? home) let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] try { @@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async createDirectory(path: string, name: string): Promise { + // Same absolute-path fence as list: never rebase a parent under the cwd. + if (!isAbsolute(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`) + } const parent = resolve(path) // The backend owns segment validation (the wire schema also refuses these, // but direct service consumers must hit the same fence). diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 3833395c11..608fe89348 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).path).toBe(missing) }) + it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { + for (const relative of ['', 'projects', './projects', '..']) { + const listFailure = await capability.list(relative).catch((error: unknown) => error) + expect(listFailure).toBeInstanceOf(DirectoryPickerError) + expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable') + expect((listFailure as DirectoryPickerError).path).toBe(relative) + const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error) + expect(createFailure).toBeInstanceOf(DirectoryPickerError) + expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed') + expect((createFailure as DirectoryPickerError).path).toBe(relative) + } + }) + it('creates one child directory and surfaces it in the next listing', async () => { const created = await capability.createDirectory(root, 'fresh') expect(created).toBe(join(root, 'fresh')) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5a6040e51f..322dc4551c 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -60,7 +60,8 @@ export interface DirectoryPickerBrowseCapability { * List one directory level. * @param path - absolute directory to list; absent lists the home directory. * @returns the level's listing with ancestry. - * @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute + * (a wire value must never rebase under the host cwd) or cannot be listed. */ list(path?: string): Promise /** @@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability { * @param path - absolute existing parent directory. * @param name - single non-blank path segment (no separators, not `.`/`..`). * @returns the created directory's absolute path. - * @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise. + * @throws {DirectoryPickerError} `directory-exists` for an existing child, + * `directory-create-failed` for a non-absolute parent or any other failure. */ createDirectory(path: string, name: string): Promise } From f43cfb24065506b9c74603088c84fc4b667ea728 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:47:56 +0800 Subject: [PATCH 019/153] =?UTF-8?q?fix(cli):=20sample=20LAN=20addresses=20?= =?UTF-8?q?once=20=E2=80=94=20trust=20and=20the=20printed=20LAN=20URL=20sh?= =?UTF-8?q?are=20the=20snapshot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web.ts re-sampled interfaces after boot, so an address change during entry.run() could advertise a LAN URL absent from the trustedHosts snapshot composePatches captured, answering 403 on arrival. resolveLanTrust now returns the single sample and AppCLIEntry exposes it for display. --- apps/cli/src/app-cli-entry.ts | 42 ++++++++++++++++++---------- apps/cli/src/web.ts | 6 ++-- apps/cli/tests/trusted-hosts.spec.ts | 31 ++++++++------------ 3 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 1002b45660..acc4482018 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -34,28 +34,31 @@ export const ALL_INTERFACES_HOST = '0.0.0.0' * authorities an all-interfaces bind is reachable by on the LAN. * @returns the addresses in interface order (possibly empty). */ -export function lanIPv4Addresses(): string[] { +function lanIPv4Addresses(): string[] { return Object.values(networkInterfaces()).flat() .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) .map(iface => iface.address) } /** - * Authorities the /api browser-trust fence must accept for one invocation: - * the machine's LAN IP literals when the effective bind is all-interfaces - * (advertised by the printed LAN URL, so they must not answer 403), followed - * by the explicit extras. Derived entries are port-less IP literals — DNS - * rebinding needs an attacker-controlled name, so an IP-literal Host is safe - * on any port, and the bound port may be OS-assigned, unknowable pre-boot. + * One LAN-trust resolution for one invocation, sampled exactly once: the + * machine's LAN IP literals when the effective bind is all-interfaces, and + * the `trustedHosts` value built from them plus the explicit extras. The + * single sample is deliberate — display must advertise only addresses the + * fence was configured with, so both read this snapshot. Derived entries are + * port-less IP literals: DNS rebinding needs an attacker-controlled name, so + * an IP-literal Host is safe on any port, and the bound port may be + * OS-assigned, unknowable pre-boot. * @param bindHost - the effective webserver bind host (CLI flag, else the yml default). * @param extra - `--trusted-host` values, in argv order. - * @returns the connection row's `trustedHosts` value (possibly empty). + * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). */ -export function resolveTrustedHosts(bindHost: string | undefined, extra: readonly string[]): string[] { - return [ - ...bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [], - ...extra, - ] +export function resolveLanTrust( + bindHost: string | undefined, + extra: readonly string[], +): { lanAddresses: string[]; trustedHosts: string[] } { + const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } } /** One profile-json key mapped onto a yml row's config field. */ @@ -126,6 +129,14 @@ export class AppCLIEntry { /** The root context, set by {@link run}. */ ctx!: Context + /** + * LAN IPv4 addresses sampled once at patch composition — the exact snapshot + * the /api trust fence was configured with. Display reads this instead of + * re-sampling, so the advertised LAN URL can never name an address the + * fence rejects. Empty unless the effective bind is all-interfaces. + */ + lanAddresses: readonly string[] = [] + private patches: PatchOptions[] = [] constructor(private readonly options: AppCLIEntryOptions) {} @@ -188,9 +199,10 @@ export class AppCLIEntry { if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot) // Source 2b: authorities for the /api browser-trust fence (rationale on - // resolveTrustedHosts). + // resolveLanTrust). const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const trustedHosts = resolveTrustedHosts(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? []) + this.lanAddresses = lanAddresses if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts) // Source 3: the frontend dist — an assembly fact of this app, never yml diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 3d7fc29ab4..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,7 +7,7 @@ */ import { fileURLToPath } from 'node:url' -import { ALL_INTERFACES_HOST, AppCLIEntry, lanIPv4Addresses } from './app-cli-entry.ts' +import { AppCLIEntry } from './app-cli-entry.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) @@ -48,7 +48,9 @@ export async function runWeb( void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = host === ALL_INTERFACES_HOST ? lanIPv4Addresses()[0] : undefined + // The entry's boot-time snapshot, not a fresh sample: the printed LAN URL + // must name an address the /api trust fence was configured with. + const lanCandidate = entry.lanAddresses[0] const localUrl = `http://${LOOPBACK_HOST}:${boundPort}` console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts index 1ed0f602b8..571a9f76b7 100644 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ b/apps/cli/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ -/** LAN-authority derivation for the /api browser-trust fence (`resolveTrustedHosts`). */ +/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { lanIPv4Addresses, resolveTrustedHosts } from '../src/app-cli-entry.ts' +import { describe, expect, it, vi } from 'vitest' +import { resolveLanTrust } from '../src/app-cli-entry.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -19,22 +19,15 @@ vi.mock('node:os', () => ({ }), })) -afterEach(() => { vi.restoreAllMocks() }) +describe('resolveLanTrust', () => { + it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { + const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) + expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) + expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) + }) -describe('lanIPv4Addresses', () => { - it('returns only non-internal IPv4 addresses, in interface order', () => { - expect(lanIPv4Addresses()).toEqual(['192.168.1.5', '10.0.0.7']) - }) -}) - -describe('resolveTrustedHosts', () => { - it('derives port-less LAN IP literals for an all-interfaces bind, ahead of the extras', () => { - expect(resolveTrustedHosts('0.0.0.0', ['harness.internal:3080'])) - .toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback or unresolved bind — extras alone stand', () => { - expect(resolveTrustedHosts('127.0.0.1', [])).toEqual([]) - expect(resolveTrustedHosts(undefined, ['lab.internal'])).toEqual(['lab.internal']) + it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) + expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) }) From c4bf919895ebe92a691fe50b3b5d3cc0d619c35f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:56:03 +0800 Subject: [PATCH 020/153] fix(host,client): default-export the picker seam; invalidate stale kind reads ds-review-bot round 3. The seam package broke the service-package export contract (named export only), so the config catalog filed it under Other libraries and default imports failed; it now default-exports DirectoryPicker like every abstract seam, and the regenerated catalog lists it as one. The picker-kind effect also let a settlement from a superseded flow open leak into the current one (close/reopen mid-describe, or a reconnect that swaps the backend): the read now resets the affordance on every open and a cleanup-toggled flag discards obsolete settlements, both directions pinned by jsdom races. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspacePicker.tsx | 14 ++++-- .../tests/workspace-picker.spec.tsx | 46 +++++++++++++++++++ packages/host/directory-picker/src/index.ts | 2 + 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9b4e9f8bf..0697f56dfe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2211,6 +2211,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) @@ -2234,7 +2235,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) -- `@deepseek-ai/dsh-host-directory-picker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 397e7320d5..3b47e405f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8cef0af8f9..2e65fc6b72 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -80,12 +80,18 @@ export function WorkspaceCreateFlow({ const [dialogPicker, setDialogPicker] = useState(false) useEffect(() => { if (!open) return + // Reset before each read: a reconnect can change the composed backend, so + // a previous open's answer must not leak into this one; and a settlement + // from a superseded open (flow closed, or a newer read started) is + // discarded via the cleanup-toggled flag. + setDialogPicker(false) + let stale = false void directoryPickerKind() - .then((kind) => { setDialogPicker(kind === 'dialog') }) + .then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') }) // A failed describe hides the entry too: the same Host that cannot - // answer describe cannot serve pickDirectory. (Post-unmount settlement - // is safe: React 18 no-ops setState on unmounted components.) - .catch(() => { setDialogPicker(false) }) + // answer describe cannot serve pickDirectory. + .catch(() => { if (!stale) setDialogPicker(false) }) + return () => { stale = true } }, [open, directoryPickerKind]) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index c7f8fd9765..9ca5304fa4 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -268,4 +268,50 @@ describe('WorkspacePicker', () => { ) expect(directoryPickerKind).not.toHaveBeenCalled() }) + + /** Render the picker with an owner-controlled `open` and a scripted kind read. */ + function togglable(directoryPickerKind: () => Promise) { + const anchorRef = anchor() + const props = (open: boolean) => ( + + ) + const view = render(props(true)) + return { setOpen: (open: boolean) => { view.rerender(props(open)) } } + } + + it('discards a kind settlement from a superseded flow open', async () => { + let resolveFirst!: (kind: string) => void + const first = new Promise((settle) => { resolveFirst = settle }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'browse') + const t = togglable(directoryPickerKind) + // Close while the first read is in flight, then let it answer 'dialog': + // the settlement is stale and must not leak into the next open. + t.setOpen(false) + await act(async () => { resolveFirst('dialog') }) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('discards a stale describe failure after a newer open already answered', async () => { + let rejectFirst!: (reason: Error) => void + const first = new Promise((_settle, reject) => { rejectFirst = reject }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'dialog') + const t = togglable(directoryPickerKind) + t.setOpen(false) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Open local folder…' }) + // The superseded read failing late must not hide the freshly shown entry. + await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) }) + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + }) }) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 322dc4551c..1c4f5e32fb 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -128,3 +128,5 @@ export abstract class DirectoryPicker extends Service { */ abstract capability(): DirectoryPickerCapability } + +export default DirectoryPicker From 99643e59a180619fde26f2da90725514cc0f8aca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:04:21 +0800 Subject: [PATCH 021/153] feat(web): miller two-pane directory browser per the full figma flow The single-column dialog missed the design's interaction model (figma 813-23126/813-23278, sibling frames of the linked node): selection is not navigation. The browser now opens as one wide level; selecting a row keeps it marked (pill + open-folder accent) and previews its children in a second 256px column across a hairline divider, a right-column pick advances one level, and the breadcrumb follows the selection. New folder becomes the design's nested create dialog ("New folder in ...", Untitled-folder placeholder, Cancel/Create), creating inside the selection and landing with the new folder selected. Open adopts the selection, falling back to the listed level, so the e2e path-edit flow is unchanged. The card is the design's fixed 600x420 with per-column scrolling. --- .../src/client/DirectoryBrowser.module.css | 136 ++++++-- .../src/client/DirectoryBrowser.tsx | 267 ++++++++++---- .../client/ui-workspace/src/client/index.ts | 6 + .../tests/directory-browser.spec.tsx | 327 +++++++++++++----- .../tests/workspace-picker.spec.tsx | 4 +- 5 files changed, 553 insertions(+), 187 deletions(-) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css index 14f3e4f2ca..f59a74aa7e 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.module.css @@ -1,13 +1,14 @@ -/* Directory-browser dialog (figma 802-56979). The shared Modal renders +/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders * headless here — mask, card, Escape only — and this module owns the figma - * frame exactly: header (title + crumbs, l3 separator), one directory level, - * and the bordered footer. Card: w600 r24, bottom pad 12, no close chrome. */ + * frame exactly: fixed 600×420 card, 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. */ .dialog.dialog { width: min(600px, 100%); - padding: 0 0 12px; - gap: 16px; + height: 420px; + padding: 0; + gap: 0; } /* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */ @@ -15,6 +16,7 @@ display: flex; flex-direction: column; gap: 8px; + flex: none; padding: 22px 14px 12px 24px; border-bottom: 1px solid var(--dsw-alias-border-l3); } @@ -95,16 +97,37 @@ color: var(--dsw-alias-label-primary); } -/* One directory level: content column pt16 px24, 28px rows with 2px gaps. */ -.level { +/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with + * the hairline divider centered between them; each column scrolls alone. */ +.content { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + gap: 20px; + padding: 16px 24px 0; +} + +.column { display: flex; flex-direction: column; gap: 2px; - padding: 16px 24px 0; - max-height: 320px; + width: 256px; + flex: none; overflow-y: auto; } +.columnWide { + width: 100%; + flex: 1 1 0; +} + +.divider { + flex: none; + width: 1px; + background: var(--dsw-alias-border-l3); +} + .row { display: flex; align-items: center; @@ -123,11 +146,22 @@ background: var(--dsw-alias-interactive-bg-hover); } +/* Selection: pill fill + the open-folder glyph in the info accent. */ +.rowSelected, +.rowSelected:hover { + background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover)); +} + .rowIcon { flex: none; color: var(--dsw-alias-label-secondary); } +.rowIconSelected { + flex: none; + color: var(--dsw-alias-button-info-fill); +} + .rowName { flex: 1 1 0; min-width: 0; @@ -145,29 +179,6 @@ color: var(--dsw-alias-label-tertiary); } -.folderRow { - cursor: default; -} - -.folderInput { - box-sizing: border-box; - flex: 1 1 0; - min-width: 0; - height: 24px; - padding: 0 6px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 6px; - outline: none; - background: transparent; - font-size: 13px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.folderInput::placeholder { - color: var(--dsw-alias-label-caption); -} - .status, .error { padding: 4px; @@ -183,12 +194,14 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer: l3 separator on top, pt12 px24, New-folder pinned left. */ +/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed + * card leaves the figma 28px below the 36px buttons. */ .footerBar { display: flex; align-items: center; gap: 8px; - padding: 12px 24px 0; + flex: none; + padding: 12px 24px 28px; border-top: 1px solid var(--dsw-alias-border-l3); } @@ -199,3 +212,58 @@ .footerAction { min-width: 72px; } + +/* Nested create dialog (figma 813:23278): a small centered card. */ +.createDialog.createDialog { + width: min(380px, 100%); + padding: 0; + gap: 0; +} + +.createBody { + display: flex; + flex-direction: column; + gap: 12px; + padding: 22px 24px 20px; +} + +.createTitle { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.createIn { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.createInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.createInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.createActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 9454693969..28c0281c56 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -1,15 +1,21 @@ /** - * The in-app workspace-directory browser (figma Harness 802-56979): breadcrumb - * header with a click-to-edit path zone, one navigable directory level, an - * inline New-folder row, and the Cancel/Open footer. 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). + * The in-app workspace-directory browser (figma Harness 813-23126 family): a + * fixed 600×420 dialog 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 + * 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). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconChevronRightOutline14, IconFolderClose16, IconPlusOutline16, Modal, + Button, 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' @@ -22,13 +28,13 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory). */ listDirectory: (path?: string) => Promise - /** Create one child directory under the listed level. */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise - /** The operator confirmed the currently listed directory. */ + /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void /** Close without picking (mask, Escape, Cancel). */ onClose: () => void - /** The owner's confirm is in flight: Open disables, the level freezes. */ + /** The owner's confirm is in flight: Open disables, the view freezes. */ busy: boolean /** Localized copy. */ t: Translate @@ -52,32 +58,73 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** One column of folder rows (the Miller view renders one or two of these). */ +function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { + entries: readonly DirectoryEntry[] + selectedPath: string | null + busy: boolean + onPick: (entry: DirectoryEntry) => void + wide: boolean +}) { + return ( +
+ {entries.filter(entry => !entry.hidden).map((entry) => { + const selected = entry.path === selectedPath + return ( + + ) + })} +
+ ) +} + /** * Render the directory-browser dialog. * @param props - owner-controlled browser props. * @returns the dialog element (null while closed, via Modal). */ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) { - const [listing, setListing] = useState(null) + // Miller state: the listed level, the selected row in it, and the selected + // folder's own listing (the right column; null while nothing is selected). + const [parent, setParent] = useState(null) + const [selected, setSelected] = useState(null) + const [child, setChild] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // New-folder state: null = no inline row; a string = the name being typed. + // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) + const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { const seq = ++requestSeq.current setLoading(true) setError(null) listDirectory(path).then((next) => { if (seq !== requestSeq.current) return - setListing(next) + setParent(next) + setSelected(null) + setChild(null) setLoading(false) setPathDraft(null) - setFolderDraft(null) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) @@ -85,11 +132,39 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [listDirectory]) + /** Select a row of the listed level and preview its children on the right. */ + const select = useCallback((entry: DirectoryEntry) => { + const seq = ++requestSeq.current + setSelected(entry) + setChild(null) + setLoading(true) + setError(null) + listDirectory(entry.path).then((next) => { + if (seq !== requestSeq.current) return + setChild(next) + setLoading(false) + }, (reason: unknown) => { + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) + }, [listDirectory]) + + /** A right-column pick advances the view one level: child becomes the level. */ + const advance = useCallback((entry: DirectoryEntry) => { + /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ + if (child === null) return + setParent(child) + select(entry) + }, [child, select]) + // Every open starts fresh at the Host home directory; closing invalidates // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { if (open) { - setListing(null) + setParent(null) + setSelected(null) + setChild(null) navigate() return } @@ -97,29 +172,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) setPathDraft(null) setFolderDraft(null) + setCreateError(null) }, [open, navigate]) - const confirmFolder = (): void => { - /* v8 ignore next -- reentry fence: the inline row only renders with a listing and a draft, and the input disables while creating. */ - if (listing === null || folderDraft === null || creatingFolder) return + /** The folder a create or Open acts on: the selection, else the listed level. */ + const targetPath = selected?.path ?? parent?.path ?? null + const targetName = selected?.name + ?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path)) + + const confirmCreate = (): void => { + /* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */ + if (targetPath === null || folderDraft === null || creatingFolder) return const name = folderDraft.trim() if (name === '') return setCreatingFolder(true) - setError(null) - createDirectory(listing.path, name).then(() => { + setCreateError(null) + createDirectory(targetPath, name).then((createdPath) => { setCreatingFolder(false) setFolderDraft(null) - navigate(listing.path) + // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the + // create target becomes the listed level and the new folder its selection. + const seq = ++requestSeq.current + setLoading(true) + listDirectory(targetPath).then((level) => { + // Same seq fence as navigate/select; the nested dialog blocks + // superseding input during this relist. + /* v8 ignore next */ + if (seq !== requestSeq.current) return + setParent(level) + setLoading(false) + select({ name, path: createdPath, hidden: false }) + }, (reason: unknown) => { + // Same seq fence as navigate/select; the nested dialog blocks + // superseding input during this relist. + /* v8 ignore next */ + if (seq !== requestSeq.current) return + setLoading(false) + setError(failureText(reason)) + }) }, (reason: unknown) => { setCreatingFolder(false) - setError(failureText(reason)) + setCreateError(failureText(reason)) }) } // After the hooks: a closed dialog renders nothing and evaluates no copy. if (!open) return null - const crumbs = listing === null ? [] : displayCrumbs(listing, t('browser.home')) + const crumbSource = child ?? parent + const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home')) + const twoPane = selected !== null return ( { if (listing !== null) setPathDraft(listing.path) }} + disabled={parent === null || busy} + /* v8 ignore next -- narrowing guard: the zone disables while the level is null. */ + onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }} /> ) @@ -183,45 +285,26 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, )} -
- {folderDraft !== null && listing !== null && ( -
- - { setFolderDraft(event.target.value) }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault() - confirmFolder() - } - if (event.key === 'Escape') { - event.stopPropagation() - setFolderDraft(null) - } - }} - /> -
+
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + )} - {listing?.entries.filter(entry => !entry.hidden).map(entry => ( - - ))} {loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
@@ -229,8 +312,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, @@ -239,13 +325,56 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
+ {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} + { if (!creatingFolder) setFolderDraft(null) }} + title={t('browser.newFolder')} + className={clsx(css.createDialog)} + headless + > +
+

{t('browser.newFolder')}

+

{t('browser.createIn', { name: targetName })}

+ { setFolderDraft(event.target.value) }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + confirmCreate() + } + if (event.key === 'Escape') { + event.stopPropagation() + if (!creatingFolder) setFolderDraft(null) + } + }} + /> + {createError !== null &&
{createError}
} +
+ + +
+
+
) } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 4c400109f5..91c62b8fa8 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -44,6 +44,9 @@ export function apply(ctx: ClientContext): void { 'browser.home': '主目录', 'browser.newFolder': '新建文件夹', 'browser.folderName': '文件夹名称', + 'browser.createIn': '在"{name}"中新建文件夹', + 'browser.untitledFolder': '未命名文件夹', + 'browser.create': '创建', 'browser.cancel': '取消', 'browser.open': '打开', 'browser.editPath': '编辑路径', @@ -54,6 +57,9 @@ export function apply(ctx: ClientContext): void { 'browser.home': 'Home', 'browser.newFolder': 'New folder', 'browser.folderName': 'Folder name', + 'browser.createIn': 'New folder in "{name}"', + 'browser.untitledFolder': 'Untitled folder', + 'browser.create': 'Create', 'browser.cancel': 'Cancel', 'browser.open': 'Open', 'browser.editPath': 'Edit path', diff --git a/packages/client/ui-workspace/tests/directory-browser.spec.tsx b/packages/client/ui-workspace/tests/directory-browser.spec.tsx index a78527d5f1..28c25b367f 100644 --- a/packages/client/ui-workspace/tests/directory-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/directory-browser.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client' import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' @@ -8,6 +8,8 @@ import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx' afterEach(cleanup) const HOME = '/home/u' +const DOCS = `${HOME}/Documents` +const HARNESS = `${DOCS}/harness` /** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */ function listingFor(path?: string): DirectoryListing { @@ -23,19 +25,31 @@ function listingFor(path?: string): DirectoryListing { ], entries: [ { name: '.config', path: `${HOME}/.config`, hidden: true }, - { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, ], }, - [`${HOME}/Documents`]: { - path: `${HOME}/Documents`, + [DOCS]: { + path: DOCS, home: HOME, crumbs: [ { name: '/', path: '/', hidden: false }, { name: 'home', path: '/home', hidden: false }, { name: 'u', path: HOME, hidden: false }, - { name: 'Documents', path: `${HOME}/Documents`, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, ], - entries: [{ name: 'harness', path: `${HOME}/Documents/harness`, hidden: false }], + entries: [{ name: 'harness', path: HARNESS, hidden: false }], + }, + [HARNESS]: { + path: HARNESS, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: 'Documents', path: DOCS, hidden: false }, + { name: 'harness', path: HARNESS, hidden: false }, + ], + entries: [], }, } const found = tree[target] @@ -57,49 +71,102 @@ function mount(overrides: Partial[0]> = {}) onOpen, onClose, busy: false, - t: (key: string) => key, + t: (key: string, params?: Record) => (params === undefined ? key : `${key}:${String(params.name)}`), ...overrides, } const view = render() return { view, props, listDirectory, createDirectory, onOpen, onClose } } +/** The rendered level columns, left-to-right. */ +function columns(): HTMLElement[] { + return screen.getAllByRole('list') +} + describe('DirectoryBrowser', () => { - it('opens at the Host home, hides hidden entries, and roots the crumbs at Home', async () => { + it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(b.listDirectory).toHaveBeenCalledWith(undefined) + expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('Documents') expect(screen.queryByText('.config')).toBeNull() - // Inside the home subtree the chain collapses to a localized Home crumb. expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() expect(screen.queryByRole('button', { name: '/' })).toBeNull() }) - it('enters a row on click and jumps back through a crumb', async () => { + it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('listitem')) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(b.listDirectory).toHaveBeenLastCalledWith(`${HOME}/Documents`) - fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + const [level, preview] = columns() + const selectedRow = within(level!).getByRole('listitem') + expect(selectedRow.textContent).toBe('Documents') + expect(selectedRow.getAttribute('aria-current')).toBe('true') + expect(within(preview!).getByRole('listitem').textContent).toBe('harness') + expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) + expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy() }) - it('edits the path from the crumb bar: Enter navigates, Escape restores', async () => { + it('advances one level when a right-column row is picked', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(within(columns()[1]!).getByRole('listitem')) + await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() }) + const [level] = columns() + const selectedRow = within(level!).getByRole('listitem') + expect(selectedRow.textContent).toBe('harness') + expect(selectedRow.getAttribute('aria-current')).toBe('true') + }) + + it('jumps back through a crumb into a fresh single-column level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + expect(screen.getByRole('listitem').getAttribute('aria-current')).toBeNull() + }) + + it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + expect(b.onOpen).toHaveBeenCalledWith(HOME) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) + expect(b.onOpen).toHaveBeenLastCalledWith(DOCS) + fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' })) + expect(b.onClose).toHaveBeenCalled() + + const busy = mount({ busy: true }) + await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) + expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) + }) + + it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') expect(input.value).toBe(HOME) - fireEvent.change(input, { target: { value: `${HOME}/Documents` } }) + fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - // Escape leaves an opened edit without navigating. + expect(columns()).toHaveLength(1) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + const again = screen.getByLabelText('browser.editPath') + fireEvent.change(again, { target: { value: ' ' } }) + fireEvent.keyDown(again, { key: 'Enter' }) + expect(b.listDirectory).toHaveBeenCalledTimes(2) + fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() - expect(screen.getByRole('listitem').textContent).toBe('harness') }) it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { @@ -114,40 +181,16 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - it('creates a folder inline and refreshes the level; failures land as alerts', async () => { - const b = mount() - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const input = screen.getByLabelText('browser.newFolder') - fireEvent.change(input, { target: { value: 'fresh' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh') }) - // The level reloads after creation (initial + post-create). - await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(HOME) }) - - b.createDirectory.mockRejectedValueOnce( - new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) - fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const retry = screen.getByLabelText('browser.newFolder') - fireEvent.change(retry, { target: { value: 'x' } }) - fireEvent.keyDown(retry, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + it('folds non-typed failures into readable text (Error message, String otherwise)', async () => { + const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') }) + b.view.rerender() + const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) }) + await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') }) + expect(raw.onOpen).not.toHaveBeenCalled() }) - it('confirms the listed directory through Open, closes through Cancel, and freezes while busy', async () => { - const b = mount() - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) - expect(b.onOpen).toHaveBeenCalledWith(HOME) - fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' })) - expect(b.onClose).toHaveBeenCalled() - - const busy = mount({ busy: true }) - await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() }) - expect(screen.getAllByRole('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true) - }) - - it('renders the full ancestry when the listing sits outside the home subtree', async () => { + it('renders the full ancestry when the level sits outside the home subtree', async () => { const outside: DirectoryListing = { path: '/srv/data', home: HOME, @@ -164,53 +207,110 @@ describe('DirectoryBrowser', () => { expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull() }) - it('folds non-typed failures into readable text (Error message, String otherwise)', async () => { - const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) }) - await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') }) - b.view.rerender() - const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) }) - await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') }) - expect(raw.onOpen).not.toHaveBeenCalled() - }) - - it('cancels the inline folder row with Escape and ignores a blank name', async () => { + it('creates a folder through the nested dialog and lands with it selected', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) - const input = screen.getByLabelText('browser.newFolder') + // The nested dialog names the create target (the selected folder). + expect(screen.getByText('browser.createIn:Documents')).toBeTruthy() + // The created folder becomes listable (like the real backend after mkdir). + b.listDirectory.mockImplementation(async (path?: string) => { + if (path === `${DOCS}/fresh`) { + return { + path: `${DOCS}/fresh`, home: HOME, + crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }], + entries: [], + } + } + if (path === DOCS) { + const docs = listingFor(DOCS) + return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] } + } + return listingFor(path) + }) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') }) + // The create target became the level and the new folder its selection. + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy() + const level = columns()[0]! + const rows = within(level).getAllByRole('listitem') + expect(rows.some(row => row.textContent === 'fresh' && row.getAttribute('aria-current') === 'true')).toBe(true) + }) + }) + + it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.createDirectory.mockRejectedValueOnce( + new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy() + const input = screen.getByLabelText('browser.folderName') + // A blank name never submits. fireEvent.change(input, { target: { value: ' ' } }) fireEvent.keyDown(input, { key: 'Enter' }) expect(b.createDirectory).not.toHaveBeenCalled() - fireEvent.keyDown(input, { key: 'Escape' }) - expect(screen.queryByLabelText('browser.newFolder')).toBeNull() - }) - - it('ignores a blank path draft on Enter', async () => { - const b = mount() - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) - const input = screen.getByLabelText('browser.editPath') - fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.change(input, { target: { value: 'x' } }) fireEvent.keyDown(input, { key: 'Enter' }) - // Only the initial home listing ran; the blank draft navigated nowhere. - expect(b.listDirectory).toHaveBeenCalledTimes(1) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') }) + fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' }) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + + // The nested Cancel button and the nested mask both close only the child dialog. + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const nested = screen.getByRole('dialog', { name: 'browser.newFolder' }) + fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' })) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const masks = document.querySelectorAll('[aria-hidden="true"]') + fireEvent.click(masks[masks.length - 1]!) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy() }) - it('drops a stale listing that resolves after a newer navigation', async () => { + it('surfaces a selection-preview failure while keeping the selection marked', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.listDirectory.mockRejectedValueOnce( + new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } })) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true') + // No preview column arrived for the failed selection. + expect(columns()).toHaveLength(1) + }) + + it('surfaces a post-create relist failure on the browser surface', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + // Creation succeeds, but relisting the target fails afterwards. + b.listDirectory.mockRejectedValueOnce(new Error('level vanished')) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'fresh' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') }) + }) + + it('drops a stale child listing that resolves after a crumb jump', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // The next navigation (into Documents) hangs; a Home-crumb jump supersedes it. let resolveSlow!: (value: DirectoryListing) => void const slow = new Promise((settle) => { resolveSlow = settle }) b.listDirectory.mockReturnValueOnce(slow) fireEvent.click(screen.getByRole('listitem')) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) - resolveSlow(listingFor(`${HOME}/Documents`)) + await waitFor(() => { expect(columns()).toHaveLength(1) }) + resolveSlow(listingFor(DOCS)) await new Promise(settle => setTimeout(settle, 0)) - // The stale Documents listing did not clobber the newer Home level. - expect(screen.getByRole('listitem').textContent).toBe('Documents') + // The superseded selection preview did not reopen the second pane. + expect(columns()).toHaveLength(1) }) it('drops a stale failure that rejects after a newer navigation', async () => { @@ -224,19 +324,82 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) rejectSlow(new Error('too late to matter')) await new Promise(settle => setTimeout(settle, 0)) - // The superseded failure surfaces no alert over the newer level. expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + it('drops a stale navigation failure that rejects after a newer jump', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + let rejectSlow!: (reason: unknown) => void + const slow = new Promise((_settle, fail) => { rejectSlow = fail }) + b.listDirectory.mockReturnValueOnce(slow) + // A slow crumb jump superseded by a second jump. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) }) + rejectSlow(new Error('late nav failure')) + await new Promise(settle => setTimeout(settle, 0)) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('drops a stale navigation listing that resolves after a newer jump', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('listitem')) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + let resolveSlow!: (value: DirectoryListing) => void + const slow = new Promise((settle) => { resolveSlow = settle }) + b.listDirectory.mockReturnValueOnce(slow) + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + resolveSlow(listingFor(undefined)) + await new Promise(settle => setTimeout(settle, 0)) + // The stale home listing did not replace the newer Documents level. + expect(screen.getByRole('listitem').textContent).toBe('harness') + }) + + it('names the create target by its path when the level reports no crumbs', async () => { + const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [] } + mount({ listDirectory: vi.fn(async () => bare) }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'browser.newFolder' }).disabled).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy() + }) + + it('refuses to close the nested dialog while the creation is in flight', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + let settleCreate!: (path: string) => void + b.createDirectory.mockReturnValueOnce(new Promise((settle) => { settleCreate = settle })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'slow' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // Escape and the mask are both inert while creating. + fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' }) + const masks = document.querySelectorAll('[aria-hidden="true"]') + fireEvent.click(masks[masks.length - 1]!) + expect(screen.getByLabelText('browser.folderName')).toBeTruthy() + settleCreate(`${HOME}/slow`) + await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() }) + }) + it('starts back at home on reopen', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('listitem')) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) b.view.rerender() b.view.rerender() await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(columns()).toHaveLength(1) expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) }) }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 0c634fbd60..2d7531a710 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -41,7 +41,7 @@ function pickingShare(): DirectoryPickingInjected { return { directoryPickerKind: vi.fn(async () => 'dialog' as const), pickDirectory: vi.fn(async () => null), - listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })), createDirectory: vi.fn(async () => '/home/u/new'), t: (key: string) => key, } @@ -59,7 +59,7 @@ function mount( const share: DirectoryPickingInjected = { directoryPickerKind: vi.fn(async () => 'dialog' as const), pickDirectory, - listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [], entries: [] })), + listDirectory: vi.fn(async () => ({ path: '/home/u', home: '/home/u', crumbs: [{ name: 'u', path: '/home/u', hidden: false }], entries: [] })), createDirectory: vi.fn(async () => '/home/u/new'), t: key => key, ...picking, From 5f3c53d8351bb616b27a0b1ef94b4e1a6df19796 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:04:50 +0800 Subject: [PATCH 022/153] style(web): keep the v8 ignore reasons inline per the invariant rule --- .../client/ui-workspace/src/client/DirectoryBrowser.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx index 28c0281c56..49cbff9fb3 100644 --- a/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-workspace/src/client/DirectoryBrowser.tsx @@ -195,17 +195,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const seq = ++requestSeq.current setLoading(true) listDirectory(targetPath).then((level) => { - // Same seq fence as navigate/select; the nested dialog blocks - // superseding input during this relist. - /* v8 ignore next */ + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) setLoading(false) select({ name, path: createdPath, hidden: false }) }, (reason: unknown) => { - // Same seq fence as navigate/select; the nested dialog blocks - // superseding input during this relist. - /* v8 ignore next */ + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) From b211a80b1fa8d3eccd1d0c5ab0cb2f7b5b1755d6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:13:20 +0800 Subject: [PATCH 023/153] fix(host,client): require fully qualified browse paths; clear the picker kind on close ds-review-bot round 4. On Windows, isAbsolute admits rooted drive-less forms (\foo, /foo) that resolve() then rebases onto the process's current drive; both browse primitives now gate on a fullyQualified check (drive letter or UNC on win32, POSIX-absolute elsewhere) with a platform test seam, per-platform unit cases, and the contract wording updated on the seam, the backend README pair, and the error messages. The picker-kind effect also kept a resolved 'dialog' across close, so a backend swapped while the menu was closed could paint the stale entry for one frame on reopen; the close arm now clears the state, pinned by a reopen-under-pending-read race test. --- docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspacePicker.tsx | 16 ++++++--- .../tests/workspace-picker.spec.tsx | 14 ++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 34 ++++++++++++++----- .../tests/service.spec.ts | 15 +++++++- packages/host/directory-picker/src/index.ts | 7 ++-- 9 files changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3b47e405f0..ce24921fe8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:121`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 2e65fc6b72..786c5cf305 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -79,11 +79,17 @@ export function WorkspaceCreateFlow({ // flow open — no cache to go stale across reconnects. const [dialogPicker, setDialogPicker] = useState(false) useEffect(() => { - if (!open) return - // Reset before each read: a reconnect can change the composed backend, so - // a previous open's answer must not leak into this one; and a settlement - // from a superseded open (flow closed, or a newer read started) is - // discarded via the cleanup-toggled flag. + if (!open) { + // Close discards the answer: a reconnect or HMR can swap the composed + // backend while the menu is closed, and the reopened menu must never + // paint the previous host's entry before the fresh read lands. + setDialogPicker(false) + return + } + // Reset before each read: the injected reader can also change identity + // while the flow stays open, and that prior answer must not leak either; + // a settlement from a superseded read is discarded via the + // cleanup-toggled flag. setDialogPicker(false) let stale = false void directoryPickerKind() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 9ca5304fa4..d8b3ab5a54 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -300,6 +300,20 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) + it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => { + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(async () => 'dialog') + // The reopened read never settles: the assertion below sees the paint + // that precedes any fresh answer. + .mockImplementation(() => new Promise(() => {})) + const t = togglable(directoryPickerKind) + await screen.findByRole('menuitem', { name: 'Open local folder…' }) + t.setOpen(false) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + it('discards a stale describe failure after a newer open already answered', async () => { let rejectFirst!: (reason: Error) => void const first = new Promise((_settle, reject) => { rejectFirst = reject }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 916db3c321..4dfc363903 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: 81357269e1d4b075f7e31b5f3ac5d4721811024a -README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76 +README.md: 160a12a8594400c9e6c565881d8e9ca0517b4e24 +README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 81357269e1..160a12a859 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -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 a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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). +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`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. 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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 06a7f7651b..4cfc4a611f 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable`/`directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 83a3f6762e..d3566e9415 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -11,7 +11,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, join, posix, resolve, win32 } from 'node:path' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -35,6 +35,22 @@ function ancestryCrumbs(target: string): DirectoryEntry[] { } } +/** + * True when the path names one fixed filesystem location regardless of + * process state: POSIX-absolute on POSIX; on Windows only drive-qualified + * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`, + * `/foo`) pass `isAbsolute` yet still resolve against the process's current + * drive. + * @param path - candidate path. + * @param platform - replaces `process.platform` for deterministic tests. + * @returns whether the path is fully qualified on the platform. + */ +export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === 'win32' + ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path) + : posix.isAbsolute(path) +} + /** Message text of an unknown thrown value. */ function messageOf(error: unknown): string { /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ @@ -81,10 +97,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string): Promise { const home = homedir() - // The seam contract takes absolute paths only; resolve() would silently - // rebase a relative or empty wire value under the host process cwd. - if (path !== undefined && !isAbsolute(path)) { - throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`) + // The seam contract takes fully qualified paths only; resolve() would + // silently rebase a relative or empty wire value under the host process + // cwd (or, for rooted drive-less Windows forms, its current drive). + if (path !== undefined && !fullyQualified(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } const target = resolve(path ?? home) let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] @@ -105,9 +122,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async createDirectory(path: string, name: string): Promise { - // Same absolute-path fence as list: never rebase a parent under the cwd. - if (!isAbsolute(path)) { - throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`) + // Same fully-qualified fence as list: never rebase a parent under the + // cwd or the current drive. + if (!fullyQualified(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`) } const parent = resolve(path) // The backend owns segment validation (the wire schema also refuses these, diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 608fe89348..4cf16107f8 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' -import BrowseDirectoryPicker from '../src/index.ts' +import BrowseDirectoryPicker, { fullyQualified } from '../src/index.ts' let root: string let capability: DirectoryPickerBrowseCapability @@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).path).toBe(missing) }) + it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => { + expect(fullyQualified('/home/x', 'linux')).toBe(true) + expect(fullyQualified('x/y', 'darwin')).toBe(false) + expect(fullyQualified('C:\\projects', 'win32')).toBe(true) + expect(fullyQualified('C:/projects', 'win32')).toBe(true) + expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true) + // Rooted but drive-less: isAbsolute accepts these, yet resolve() would + // inject the process's current drive. + expect(fullyQualified('\\foo', 'win32')).toBe(false) + expect(fullyQualified('/foo', 'win32')).toBe(false) + expect(fullyQualified('C:relative', 'win32')).toBe(false) + }) + it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { for (const relative of ['', 'projects', './projects', '..']) { const listFailure = await capability.list(relative).catch((error: unknown) => error) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 1c4f5e32fb..77a63bb568 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -60,8 +60,9 @@ export interface DirectoryPickerBrowseCapability { * List one directory level. * @param path - absolute directory to list; absent lists the home directory. * @returns the level's listing with ancestry. - * @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute - * (a wire value must never rebase under the host cwd) or cannot be listed. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully + * qualified (a wire value must never resolve against the host cwd or, on + * Windows, its current drive) or cannot be listed. */ list(path?: string): Promise /** @@ -70,7 +71,7 @@ export interface DirectoryPickerBrowseCapability { * @param name - single non-blank path segment (no separators, not `.`/`..`). * @returns the created directory's absolute path. * @throws {DirectoryPickerError} `directory-exists` for an existing child, - * `directory-create-failed` for a non-absolute parent or any other failure. + * `directory-create-failed` for a parent that is not fully qualified or any other failure. */ createDirectory(path: string, name: string): Promise } From 987ecc2ec2ab51737a57b8f49d9f233dc0d1b69b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:27:26 +0800 Subject: [PATCH 024/153] fix(host): require complete UNC forms in the fully-qualified path check ds-review-bot round 5: '\\' and '\\server' satisfy win32.isAbsolute and the previous two-separator test, yet resolve() collapses them to drive-relative roots. The UNC arm now requires server and share components; incomplete prefixes reject with the business codes, covered per-platform. --- packages/host/directory-picker-browse/README.i18n.yaml | 4 ++-- packages/host/directory-picker-browse/README.md | 2 +- packages/host/directory-picker-browse/README.zh.md | 2 +- packages/host/directory-picker-browse/src/index.ts | 8 ++++---- .../host/directory-picker-browse/tests/service.spec.ts | 5 +++++ 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4dfc363903..e41ef9a0ae 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: 160a12a8594400c9e6c565881d8e9ca0517b4e24 -README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0 +README.md: 9655fdb538da1addb4d10dbee6210965400394c8 +README.zh.md: 1c7b0c36cbb73286f3d4e6a48748c4415f9ff0e4 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 160a12a859..9655fdb538 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -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`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. 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). +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. 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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4cfc4a611f..1c7b0c36cb 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index d3566e9415..561e168421 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -38,16 +38,16 @@ function ancestryCrumbs(target: string): DirectoryEntry[] { /** * True when the path names one fixed filesystem location regardless of * process state: POSIX-absolute on POSIX; on Windows only drive-qualified - * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`, - * `/foo`) pass `isAbsolute` yet still resolve against the process's current - * drive. + * (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less + * forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) + * pass `isAbsolute` yet still resolve against the process's current drive. * @param path - candidate path. * @param platform - replaces `process.platform` for deterministic tests. * @returns whether the path is fully qualified on the platform. */ export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean { return platform === 'win32' - ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path) + ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path) : posix.isAbsolute(path) } diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 4cf16107f8..3f214760b7 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -76,11 +76,16 @@ describe('BrowseDirectoryPicker', () => { expect(fullyQualified('C:\\projects', 'win32')).toBe(true) expect(fullyQualified('C:/projects', 'win32')).toBe(true) expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true) + expect(fullyQualified('//server/share/deep', 'win32')).toBe(true) // Rooted but drive-less: isAbsolute accepts these, yet resolve() would // inject the process's current drive. expect(fullyQualified('\\foo', 'win32')).toBe(false) expect(fullyQualified('/foo', 'win32')).toBe(false) expect(fullyQualified('C:relative', 'win32')).toBe(false) + // Incomplete UNC prefixes collapse to drive-relative roots under resolve(). + expect(fullyQualified('\\\\', 'win32')).toBe(false) + expect(fullyQualified('\\\\server', 'win32')).toBe(false) + expect(fullyQualified('\\\\server\\', 'win32')).toBe(false) }) it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { From a00678a44452df0dd341eabe4ad47804470ebcb0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 28 Jul 2026 18:48:57 +0800 Subject: [PATCH 025/153] feat(web): give a multi-line command one prompt row per line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `command` carrying two shell commands on two lines rendered as one row: `.command` had `white-space: nowrap`, so the two collapsed into a single ellipsized line that read as one command with stray arguments. Render one prompt row per command line, and move the run-state dot out of flow into a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's text metrics to line up. The dot stays exactly one per card, on the first row. The exit status the view carries is the whole call's and bash reports no per-command status, so a dot per line would assert, of a line that succeeded inside a failing call, that the line itself failed. The single visually hidden label keeps the same scope, since one label per row would read to assistive technology as several distinct outcomes. Fixture turn 60's command becomes two lines, so the built-bundle snapshot pins the layout and its dot distribution (`dotsPerPromptRow: [1, 0]`), and the e2e adds that the dot starts left of the card surface — geometry jsdom cannot compute. Both READMEs now also record that this package's user-facing copy is inline Chinese, since zero-cordis atoms have no route to `ctx.locale`; extracting it belongs to the repo-wide localization work. --- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 9 +++-- .../2026-07-28-web-terminal-card.zh.md | 9 +++-- apps/web/tests/navigation-panes.e2e.ts | 9 ++++- apps/web/tests/terminal-card.snapshot.ts | 32 ++++++++++++++-- .../client/connection/src/client/fixture.ts | 4 +- .../tests/terminal-card.spec.tsx | 11 ++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 3 +- packages/client/ui-primitives/README.zh.md | 3 +- .../src/TerminalBlock.module.css | 37 ++++++++++++++----- .../ui-primitives/src/TerminalBlock.tsx | 21 +++++++++-- .../tests/terminal-block.spec.tsx | 29 ++++++++++++++- 13 files changed, 140 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 9739ef195a..ca4d5e62e1 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 0c10e867afe6a9cc7085206709ae94df54bc4ed0 -2026-07-28-web-terminal-card.zh.md: e680f34bc5852430e00a5970389be0280b086f3b +2026-07-28-web-terminal-card.md: 7a4672e110cb4333df8d7a29a772de8bf2717d84 +2026-07-28-web-terminal-card.zh.md: df49ef45e000a464715635ca32e42848994fc7ff diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 0c10e867af..7a4672e110 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -16,7 +16,8 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ The component's contract: -- **Prompt line.** A run-state dot, then a shortened cwd label, then the command verbatim. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. The dot is `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. It leads the line because the first question a reader has about a shell command is whether it is still running, and without the dot that had to be inferred from the absence of output — which a settled command producing no output also looks like. `StateDot` is `aria-hidden`, so a visually hidden text label rides beside it. +- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. +- **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes. - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding. - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends. - **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters, and a carriage return reduces its line to the final redraw, which is what a terminal shows for progress output. @@ -50,13 +51,13 @@ Inline rendering is licensed for the terminal intent alone. A future intent that ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. +`packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, per-line carriage-return redraws, and CRLF preservation. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly. `packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files. -`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes; that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. +`apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 66 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`); that turn also carries what turn 60's three clean lines cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit recovered from the trailing marker. -`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet), and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one. +`apps/web/tests/navigation-panes.e2e.ts` adds the real-browser scenario over its existing `echo NAVIGATION_OK` bash call, asserting what jsdom cannot compute: squeezing the output pane below its content width leaves the line at one row and gives the pane horizontal overflow, the run-state dot resolves to the green success token rather than to a literal color (a `--dsw-*` var has no computed value at all without the real theme stylesheet) and starts to the left of the card surface itself, and the copy control reaches the page's own async Clipboard API rather than the `execCommand` fallback. Its `details-open.expected.md` golden was refreshed for the panel's new terminal card. That refresh also absorbed a stale `Input json` line and its copy button, which the shiki `CodeBlock` change already on master left behind — verified as failing on a clean rebuilt tree before this change, so it is a correction carried along, not an effect of this one. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index e680f34bc5..df49ef45e0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -16,7 +16,8 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c 该组件的契约: -- **提示符行。** 一枚运行状态点,其后是缩短的 cwd 标签,再原样跟随命令。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。状态点是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。它位于行首,因为读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有该状态点时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。`StateDot` 是 `aria-hidden`,因此其旁伴随一处视觉隐藏的文本标签。 +- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。 +- **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。 - **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM;回车会把所在行归约为最后一次重绘,这正是终端对进度输出的呈现。 @@ -50,13 +51,13 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Testing -`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 +`packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、逐行的回车重绘,以及 CRLF 的保留。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。 `packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。 -`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态;该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 +`apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 66 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`);该轮还承载第 60 轮三行干净输出无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及从末尾标记还原出的非零退出码。 -`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值);复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。 +`apps/web/tests/navigation-panes.e2e.ts` 在其既有的 `echo NAVIGATION_OK` bash 调用上新增真实浏览器场景,断言 jsdom 无法计算的部分:把输出面板挤压到窄于内容宽度后,行仍保持单行且面板产生横向溢出;运行状态点解析为绿色的 success token,而不是字面颜色(没有真实主题样式表时,`--dsw-*` 变量根本不产生计算值),且其起点位于卡片表面本身的左侧;复制控件走的是页面自身的异步 Clipboard API,而非 `execCommand` 兜底路径。其 `details-open.expected.md` 基准已为面板的新终端卡片重新录制。该次录制同时吸收了一行陈旧的 `Input json` 及其复制按钮——那是 master 上已有的 shiki `CodeBlock` 改动留下的;在干净并重新构建的工作树上验证过它本就失败,因此那是被顺带修正的部分,而非本次改动的影响。 ## Related diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 1e022489ee..2883371bdb 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -222,16 +222,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { state: node.getAttribute('data-state'), color: getComputedStyle(node as HTMLElement).color, success, - label: node.parentElement?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null, + // One label per card (the state is the call's), so it hangs off the + // prompt column rather than the row the dot sits in. + label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null, // The dot precedes the prompt label in document order, which is what // puts it to the left of the `$`. beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!) === Node.DOCUMENT_POSITION_FOLLOWING, + // The dot is out of flow in the card's left gutter, so it starts to the + // left of the card surface itself — the geometry jsdom cannot compute. + leftOfCard: (node as HTMLElement).getBoundingClientRect().left + < node.closest('[data-terminal]')!.getBoundingClientRect().left, } }) expect(dot.state).toBe('done') expect(dot.label).toBe('已完成') expect(dot.beforePrompt).toBe(true) + expect(dot.leftOfCard).toBe(true) // Resolved through the theme token, not a literal hex in the component. expect(dot.success).toMatch(/^rgb/) expect(dot.color).toBe(dot.success) diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts index 80cc4fd2b4..b6e53d972f 100644 --- a/apps/web/tests/terminal-card.snapshot.ts +++ b/apps/web/tests/terminal-card.snapshot.ts @@ -111,7 +111,14 @@ function readCard(card: Element) { const status = card.querySelector('[class*="_status_"]') const expander = card.querySelector('button[aria-expanded]') return { - prompt: `${card.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${card.querySelector('[class*="_command_"]')?.textContent ?? ''}`, + // One entry per command line: a multi-line command is one row per line. + prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row => + `${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`), + // Dots per prompt row: exactly one, on the first row — the exit status the + // view carries is the whole call's, so a dot per line would assert a + // per-line outcome bash does not report. + dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row => + row.querySelectorAll('[data-state]').length), status: status === null ? null : status.textContent, copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null, lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent), @@ -187,6 +194,9 @@ it('renders the keyed bash row with a resident terminal card', async () => { "color: var(--dsw-alias-state-error-primary);", ], "copy": "复制", + "dotsPerPromptRow": [ + 1, + ], "expander": { "expanded": "false", "label": "展开其余 14 行输出", @@ -202,7 +212,9 @@ it('renders the keyed bash row with a resident terminal card', async () => { "1 of 4 checks failed", "[exit code: 1]", ], - "prompt": "nested pnpm run check", + "prompt": [ + "nested pnpm run check", + ], "runState": "error", "runStateLabel": "失败", "status": "退出码 1", @@ -229,13 +241,20 @@ it('the fallback row reaches the same card through its expand control', async () { "colors": [], "copy": "复制", + "dotsPerPromptRow": [ + 1, + 0, + ], "expander": null, "lines": [ "total 2", "drwxr-xr-x fixture", "-rw-r--r-- demo.txt", ], - "prompt": "fixture ls -la", + "prompt": [ + "fixture ls -la", + "fixture echo done", + ], "runState": "done", "runStateLabel": "已完成", "status": null, @@ -302,6 +321,9 @@ it('the details panel Output section renders the same call at full height', asyn "color: var(--dsw-alias-label-tertiary);", ], "copy": "复制", + "dotsPerPromptRow": [ + 1, + ], "expander": { "expanded": "false", "label": "展开其余 6 行输出", @@ -326,7 +348,9 @@ it('the details panel Output section renders the same call at full height', asyn "[exit code: 1]", ], "panelLines": 16, - "prompt": "nested pnpm run check", + "prompt": [ + "nested pnpm run check", + ], "runState": "error", "runStateLabel": "失败", "status": "退出码 1", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 401d3a76a4..55907be48a 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -185,7 +185,9 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') + // A two-line command, so the fixture covers the terminal card's one-row-per- + // command-line prompt (and that the card still marks the call exactly once). + toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 49fcf48d4c..e6ff5e7eed 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -137,6 +137,17 @@ describe('chat row terminal body', () => { expect(view.getByText('line-5')).toBeTruthy() }) + it('renders a multi-line command as one prompt row per line', () => { + const view = render() + fireEvent.click(view.container.querySelector('button')!) + const rows = view.container.querySelectorAll('[class^="_promptLine_"]') + expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done']) + // Still one dot for the call, on the first row. + expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1) + }) + it('a running terminal call expands to the prompt line with no output yet', () => { const view = render() fireEvent.click(view.container.querySelector('button')!) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 495831a377..753c33fdea 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: bc880333157f5cb790cbaf854b01aaf8ea6c1cc9 -README.zh.md: 6593e4518d09eaccee7e55874b1162943c5eb3bd +README.md: 9e5384f84c3714d327b7b4ceaba8fb0a2cd67e7b +README.zh.md: 9f2a362e4a1e03bffde1d7b218bf94ed41ffd168 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index bc88033315..9e5384f84c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: a prompt line (a run-state `StateDot` ahead of the shortened `cwd` label, then the command), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. The dot reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries a visually hidden text label because `StateDot` is `aria-hidden`. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Model Experience @@ -25,4 +25,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. +- **This package's user-facing copy is inline Chinese, not localized** — the atoms are zero-cordis and so cannot reach `ctx.locale`; `TerminalBlock`'s exit-code and signal pills, its copy and expand controls, and `CodeBlock`'s copy control are all hardcoded. This matches the repo-wide state the locale package records (only the Settings surface is translated); extracting these into the `zh`/`en` dictionaries needs a localization channel for zero-cordis atoms and belongs to that repo-wide extraction. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, while cursor movement, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 6593e4518d..9f2a362e4a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:提示行(缩短后的 `cwd` 标签之前是一枚运行状态 `StateDot`,其后是命令)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。该状态点用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它同时携带一处视觉隐藏的文本标签。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## 模型体验 @@ -25,4 +25,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 +- **本包面向用户的文案是内联中文,未做本地化**:这些原子组件是 zero-cordis 的,因此拿不到 `ctx.locale`;`TerminalBlock` 的退出码与信号胶囊、它的复制与展开控件,以及 `CodeBlock` 的复制控件全部硬编码。这与 locale 包记录的全仓现状一致(只有 Settings 表面做了翻译);把它们抽取进 `zh`/`en` 字典需要为 zero-cordis 原子组件提供一条本地化通道,属于那次全仓抽取的范围。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,而光标移动、清屏和备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 6d470ec634..aec2971921 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -7,17 +7,23 @@ .block { --dsl-terminal-radius: 12px; --dsl-terminal-line-height: 22px; + /* Reserved strip to the left of the card for the per-line run-state dots. + The dots sit outside the card surface, so a reader scans command state + down one column without the dots competing with the commands themselves. */ + --dsl-terminal-gutter: 30px; position: relative; - margin: 16px 0; + margin: 16px 0 16px var(--dsl-terminal-gutter); color: var(--dsw-alias-label-primary); background: var(--dsw-alias-markdown-code-block); border-radius: var(--dsl-terminal-radius); } +/* Top-aligned: the status pill and copy control stay on the first prompt row + however many command lines the card carries. */ .header { display: flex; - align-items: center; + align-items: flex-start; gap: 12px; padding: 9px 14px; background: var(--dsw-alias-markdown-code-block-banner); @@ -25,22 +31,33 @@ border-top-right-radius: var(--dsl-terminal-radius); } -/* The prompt row is the only element allowed to shrink; the status pill and - the copy control keep their intrinsic width. */ +/* One row per command line. The prompt column is the only element allowed to + shrink; the status pill and the copy control keep their intrinsic width. */ .prompt { display: flex; - align-items: baseline; - gap: 8px; + flex-direction: column; min-width: 0; flex: 1; font: var(--dsw-font-markdown-code-block); } -/* The dot sits on the prompt row's baseline box, which is a code-font line, so - it is centered against that line's box rather than sitting on the baseline. */ +.promptLine { + position: relative; + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + line-height: var(--dsl-terminal-line-height); +} + +/* Out of flow in the gutter, so a dot neither indents its command nor depends + on the command's own text metrics to line up with it. Centered against the + row's line box rather than sitting on the code font's baseline. */ .runState { - flex: none; - align-self: center; + position: absolute; + left: calc(-1 * var(--dsl-terminal-gutter)); + top: 50%; + transform: translateY(-50%); } /* The dot is aria-hidden; this is its text label for assistive technology. */ diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index c5278dd7df..3ae7ca7113 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -147,6 +147,13 @@ export function TerminalBlock({ const status = statusText(exitCode, signal) const state = runState(running, exitCode, signal) + // A multi-line command gets one prompt row per line, so a two-command shell + // snippet reads as the two commands it is instead of collapsing into one + // ellipsized row. A trailing newline is a terminator, not an empty command. + const commandLines = useMemo(() => { + const body = command.endsWith('\n') ? command.slice(0, -1) : command + return body.split('\n') + }, [command]) const empty = text.trim() === '' const hidden = lines.length - maxLines const capped = hidden > 0 && !expanded @@ -159,10 +166,18 @@ export function TerminalBlock({
- {state.label} - {cwd === undefined ? '$' : promptLabel(cwd, home)} - {command} + {commandLines.map((line, index) => ( +
+ {/* One dot for the card, on the first row: the exit status the + view carries is the whole call's, and bash reports no + per-command status, so a dot per row would assert a + per-line outcome nothing here knows. */} + {index === 0 && } + {cwd === undefined ? '$' : promptLabel(cwd, home)} + {line} +
+ ))}
{status !== undefined && {status}} {!running && !empty && ( diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx index 486aac1197..dd9af07b8e 100644 --- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx @@ -34,6 +34,11 @@ function runStateOf(container: HTMLElement): { state: string | null; label: stri } } +/** The prompt rows as `
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} +
+ {parent !== null && ( + + )} + {twoPane && } + {twoPane && child !== null && ( + + )} +
{loading &&
{t('browser.loading')}
} {error !== null &&
{error}
}
@@ -341,7 +352,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, - + {/* Retrying needs an occupant to serve the flow; without one the + * button would open a flow nobody can answer or cancel. */} + )} > diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 18ddd8b1fd..490a86d782 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -303,6 +303,20 @@ describe('WorkspacePicker', () => { expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) + it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => { + const b = mount([], vi.fn(async () => { throw new Error('adoption failed') })) + chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() }) + // The occupant unloads while the error dialog is up: retrying would open + // a flow nobody can serve or cancel, so the button goes inert. + act(() => { b.occupancy.flip(false) }) + expect(screen.getByRole('button', { name: 'Choose again' }).disabled).toBe(true) + // Cancel stays the way out, and the menu actions are usable again. + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + }) + it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { const b = mount([]) chooseItem('Open local folder…') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 9588943980..4320c46148 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: 2f1994100f0fb8fcadae46094267057b3966197e -README.zh.md: a149133a13ca99f176680c68b4931b9673f4aaa8 +README.md: 155b18eecb46d704dc181e29fdf7eb67fac42839 +README.zh.md: 154eeb571171116ae98e46efe77efd45986f67ea diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 2f1994100f..155b18eecb 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -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 materializes at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings): a cut level keeps the name-sorted head, counts hidden rows against the bound, stops probing once the bound is hit, and reports `truncated: true` so the client can say the level is incomplete. 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). +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). 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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index a149133a13..154eeb5711 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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 网页端对目录列举采用的同一上限):被截断的层级保留按名排序的头部、隐藏行计入上限、达到上限即停止探测,并报告 `truncated: true`,供客户端提示层级不完整。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index c1e8626bed..f83b0fcb86 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -27,7 +27,8 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index b9699cd612..20ebef1c62 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-browse */ -import { mkdir, readdir, stat } from 'node:fs/promises' +import { mkdir, opendir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' import type { Context } from 'cordis' @@ -53,6 +53,35 @@ export function fullyQualified(path: string, platform: NodeJS.Platform = process : posix.isAbsolute(path) } +/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */ +export interface ListingCandidate { + /** Base name within the streamed level. */ + name: string + /** Dirent says directory (no probe needed). */ + isDirectory: boolean + /** Dirent says symlink (enterability needs a stat probe). */ + isSymbolicLink: boolean +} + +/** + * Insert a streamed candidate into the name-sorted bounded window, evicting + * the name-largest candidate when the window exceeds `keep`. Memory over an + * arbitrarily large level therefore stays O(keep) regardless of how many + * children the directory holds. + * @param window - the name-ascending window, mutated in place. + * @param candidate - the streamed candidate to place. + * @param keep - the window bound. + * @returns true when an eviction happened (the level has candidates beyond the window). + */ +export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { + const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0) + if (at === -1) window.push(candidate) + else window.splice(at, 0, candidate) + if (window.length <= keep) return false + window.pop() + return true +} + /** Message text of an unknown thrown value. */ function messageOf(error: unknown): string { /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ @@ -127,25 +156,32 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } const target = resolve(path ?? home) - let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] + // Stream the level (opendir, one dirent at a time) into a name-sorted + // window of maxEntries + 1 candidates: memory stays bounded no matter how + // many children the directory holds, the window keeps the name-sorted + // head, and the +1 slot lets an in-window extra row prove the cut. A + // window candidate that turns out non-enterable (broken symlink) is not + // backfilled from beyond the window — an eviction already marks the + // level truncated, which stays the honest answer. + const keep = this.config.maxEntries + 1 + const window: ListingCandidate[] = [] + let evicted = false try { - const dirents = await readdir(target, { withFileTypes: true }) - names = dirents.map(dirent => ({ - name: dirent.name, - isDirectory: dirent.isDirectory(), - isSymbolicLink: dirent.isSymbolicLink(), - })) + const level = await opendir(target) + for await (const dirent of level) { + // Only rows a browser could enter contend for the window; dirent + // says "directory" outright, a symlink needs the later stat probe. + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue + const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } + if (boundedInsert(window, candidate, keep)) evicted = true + } } catch (error: unknown) { throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } - // Sort candidates before probing so the bound keeps the name-sorted head - // of the level and probing (symlink stat) stops with the bound instead of - // touching every child of an oversized directory. - names.sort((a, b) => a.name.localeCompare(b.name)) const entries: DirectoryEntry[] = [] - let truncated = false - for (const entry of names) { - const row = await directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink) + let truncated = evicted + for (const candidate of window) { + const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink) if (row === null) continue if (entries.length === this.config.maxEntries) { truncated = true diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 8e7a2ba049..2b01d0d29e 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' -import BrowseDirectoryPicker, { fullyQualified } from '../src/index.ts' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts' +import type { ListingCandidate } from '../src/index.ts' let root: string let capability: DirectoryPickerBrowseCapability @@ -21,6 +22,13 @@ beforeAll(async () => { await writeFile(join(root, 'notes.txt'), 'not a directory') await symlink(join(root, 'projects'), join(root, 'linked'), 'junction') await symlink(join(root, 'gone'), join(root, 'broken'), 'junction') + try { + await symlink(join(root, 'notes.txt'), join(root, 'file-link')) + } catch { + // Windows denies unprivileged file symlinks; the file-link row only + // feeds the POSIX lanes' coverage of the symlink-to-file arm, and every + // assertion below expects it to be filtered out anyway. + } const ctx = new Context() const fiber = ctx.plugin(BrowseDirectoryPicker) @@ -63,11 +71,31 @@ describe('BrowseDirectoryPicker', () => { const exact = await bounded.list(join(root, 'projects')) expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) expect(exact.truncated).toBe(false) + // A level that fits the window but exceeds the bound (two rows, bound + // one): the in-window extra row proves the cut without any eviction. + await mkdir(join(root, 'projects', 'harness', 'a')) + await mkdir(join(root, 'projects', 'harness', 'b')) + const inWindow = await bounded.list(join(root, 'projects', 'harness')) + expect(inWindow.entries.map(entry => entry.name)).toEqual(['a']) + expect(inWindow.truncated).toBe(true) } finally { await fiber.dispose() } }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { + const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) + const window: ListingCandidate[] = [] + expect(boundedInsert(window, candidate('m'), 2)).toBe(false) + expect(boundedInsert(window, candidate('z'), 2)).toBe(false) + // A smaller name lands in place and pushes the current largest out. + expect(boundedInsert(window, candidate('a'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + // A name beyond the window's tail enters last and leaves immediately. + expect(boundedInsert(window, candidate('t'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + }) + it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { const listing = await capability.list(join(root, 'projects')) const tail = listing.crumbs.at(-1)! diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a23066eb1..483167a961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2795,6 +2795,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ From 7503390590d486590f7dd8008ab7d35c884b50a5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 04:42:13 +0800 Subject: [PATCH 085/153] fix(host): cancellable listing scans and O(log window) insertion capability.list gains an optional AbortSignal threaded from the RPC carrier's request signal (the pickDirectory pattern): a disconnected or timed-out caller stops the opendir loop instead of the scan outliving its caller, and the abort surfaces as its own reason rather than a directory-unreadable dressing. boundedInsert rejects a full window's at-or-beyond-tail candidate on one comparison and binary-inserts retained candidates, so an oversized level no longer pays a window scan per dirent. --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api-proxy.ts | 6 ++-- packages/host/apiproxy/src/api/host.ts | 5 +++- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 8 +++--- .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 28 +++++++++++++++---- .../tests/service.spec.ts | 17 ++++++++++- packages/host/directory-picker/src/index.ts | 5 +++- 17 files changed, 68 insertions(+), 27 deletions(-) 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 52bd6fd263..5696f35720 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: 7f78f7db0cca36c5aaeeb4efcab51d826945a4a1 -2026-07-28-directory-picker-capability-seam.zh.md: 31537fe1f21c1acbca73763bdd20e6d5f91a8d5e +2026-07-28-directory-picker-capability-seam.md: 9d885abe394cfd30a174f07f6ac9432b774188b6 +2026-07-28-directory-picker-capability-seam.zh.md: 6dd1b66b508f5f06d6a150b6c45f0da7d36bf2ce 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 7f78f7db0c..9d885abe39 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 @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **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. - **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. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **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. 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. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 31537fe1f2..6dd1b66b50 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 @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 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 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8db82e5cc7..3e70c22fbc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:114`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:127`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5c422d6586..314c707f4c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:128`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0a029072d0..a11d8c4157 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1160,7 +1160,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) - case 'host.listDirectory': return this.api.host.listDirectory(request) + case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9ca1d81ca9..e8761181ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1669,7 +1669,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryPickerBrowseCapability', - declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', + declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, { name: 'DirectoryPickerCapabilities', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7b7beacec8..d4c4268088 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1076,7 +1076,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async listDirectory(request) { + async listDirectory(request, signal) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'browse') { return err(request, { @@ -1086,7 +1086,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } try { - return ok(request, await capability.list(request.payload.path)) + // The carrier's signal follows the caller: a disconnect or timeout + // stops the backend's directory scan instead of outliving it. + return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { return err(request, directoryError(error)) } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 0338494bed..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -62,10 +62,13 @@ export interface HostApi { /** * List one directory level for the in-app browser; an absent path lists the * host account's home directory. Only served under the `browse` capability; - * unreadable or missing targets fail with `directory-unreadable`. + * unreadable or missing targets fail with `directory-unreadable`. The + * carrier's request signal follows the caller, stopping the backend's scan + * on disconnect or timeout. */ listDirectory( request: RpcRequest<{ path?: string }>, + signal: AbortSignal, ): Promise> /** diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1f9bf3fde7..5398ce4848 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -64,7 +64,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, - 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) }, + 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 9c52b62a61..f04850c34d 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -172,9 +172,9 @@ const BROWSE_STUB: DirectoryPickerCapability = { describe('host.listDirectory / host.createDirectory', () => { it('serves listings and creation through the browse capability, defaulting to home', async () => { const { api } = await harness(undefined, BROWSE_STUB) - const home = await api.host.listDirectory(request({})) + const home = await api.host.listDirectory(request({}), new AbortController().signal) expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory(request({ path: '/home/user/projects' })) + const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } }) const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' })) expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } }) @@ -182,7 +182,7 @@ describe('host.listDirectory / host.createDirectory', () => { it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => { const { api } = await harness(undefined, BROWSE_STUB) - expect((await api.host.listDirectory(request({ path: '/denied' }))).result).toMatchObject({ + expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, }) expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ @@ -195,7 +195,7 @@ describe('host.listDirectory / host.createDirectory', () => { it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() - expect((await api.host.listDirectory(request({}))).result).toMatchObject({ + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4320c46148..96151039b3 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: 155b18eecb46d704dc181e29fdf7eb67fac42839 -README.zh.md: 154eeb571171116ae98e46efe77efd45986f67ea +README.md: 9543fc89f1314d05d02df72e9b5d86af21fdad66 +README.zh.md: dd314c5b5a709b2cf2e6840911fd665ead7d7e87 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 155b18eecb..9543fc89f1 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -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). 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). +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). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 154eeb5711..dd314c5b5a 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 20ebef1c62..be27c5a2d4 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -74,9 +74,22 @@ export interface ListingCandidate { * @returns true when an eviction happened (the level has candidates beyond the window). */ export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { - const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0) - if (at === -1) window.push(candidate) - else window.splice(at, 0, candidate) + // Full window, name at or beyond the tail: one comparison rejects, so an + // oversized level costs O(1) per candidate past the head instead of a + // window scan (100k children against a 1,001 window must not approach + // 10^8 comparisons). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail + if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true + // Binary insertion keeps a retained candidate at O(log keep) comparisons. + let lo = 0 + let hi = window.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid + else lo = mid + 1 + } + window.splice(lo, 0, candidate) if (window.length <= keep) return false window.pop() return true @@ -131,7 +144,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private readonly browseCapability: DirectoryPickerCapability = { kind: 'browse', - list: path => this.list(path), + list: (path, signal) => this.list(path, signal), createDirectory: (path, name) => this.createDirectory(path, name), } @@ -147,7 +160,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { return this.browseCapability } - private async list(path?: string): Promise { + private async list(path?: string, signal?: AbortSignal): Promise { const home = homedir() // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process @@ -169,6 +182,9 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { try { const level = await opendir(target) for await (const dirent of level) { + // A disconnected/timed-out caller stops the scan here; throwing out + // of the loop closes the directory handle via the iterator's return. + signal?.throwIfAborted() // Only rows a browser could enter contend for the window; dirent // says "directory" outright, a symlink needs the later stat probe. if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue @@ -176,6 +192,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (boundedInsert(window, candidate, keep)) evicted = true } } catch (error: unknown) { + // An abort is the caller's own reason, not an unreadable directory. + signal?.throwIfAborted() throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } const entries: DirectoryEntry[] = [] diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 2b01d0d29e..99366e8754 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -83,6 +83,19 @@ describe('BrowseDirectoryPicker', () => { } }) + it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { + const gone = new AbortController() + gone.abort(new Error('caller left')) + // The abort surfaces as-is, not dressed as an unreadable directory. + await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // A live signal changes nothing about ordinary failures. + const live = new AbortController() + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) const window: ListingCandidate[] = [] @@ -91,9 +104,11 @@ describe('BrowseDirectoryPicker', () => { // A smaller name lands in place and pushes the current largest out. expect(boundedInsert(window, candidate('a'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) - // A name beyond the window's tail enters last and leaves immediately. + // A name at or beyond the full window's tail rejects on one comparison. expect(boundedInsert(window, candidate('t'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + expect(boundedInsert(window, candidate('m'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) }) it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 3b4b60562f..4dba9c9c22 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -65,13 +65,16 @@ export interface DirectoryPickerBrowseCapability { /** * List one directory level. * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the scan (a stalled network + * directory must not outlive a disconnected caller) and rejects with the + * abort reason. * @returns the level's listing with ancestry; backends bound the complete * result, and a cut level reports `truncated`. * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully * qualified (a wire value must never resolve against the host cwd or, on * Windows, its current drive) or cannot be listed. */ - list(path?: string): Promise + list(path?: string, signal?: AbortSignal): Promise /** * Create one child directory under an existing parent. * @param path - absolute existing parent directory. From 1a9b84b19e7973fc00db89f5da52dea507d15d12 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 04:46:49 +0800 Subject: [PATCH 086/153] fix(host): restart home on early-edit cancel; clamp the dialog to short viewports Escape canceling a path edit opened before any level listed relaunches the home listing instead of stranding a blank picker (the editor had superseded the initial request while parent was still null). The card's height clamps to the viewport (min(420px, 100dvh - 32px)); header and footer are flex-none and the columns scroll, so Open/Cancel stay reachable on landscape phones and short embedded windows. --- .../client/connection/tests/fixture.spec.ts | 4 ++-- .../src/client/DirectoryBrowser.module.css | 7 +++++-- .../src/client/DirectoryBrowser.tsx | 7 ++++++- .../tests/directory-browser.spec.tsx | 21 +++++++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 3c8256ac3e..15fecad404 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -320,13 +320,13 @@ describe('createFixtureApi', () => { const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' })) + const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' })) + const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) if (!root.result.ok) throw new Error('root list failed') expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) 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 4e9378290b..82be42efbe 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -1,12 +1,15 @@ /* Directory-browser dialog (figma 813-23126 family). The shared Modal renders * headless here — mask, card, Escape only — and this module owns the figma - * frame exactly: fixed 600×420 card, header (title + crumbs, l3 separator), + * frame: 600×420 card (viewport-clamped), 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. */ +/* Short viewports clamp the card: header/footer are flex-none and the + * 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: 420px; + height: min(420px, calc(100dvh - 32px)); padding: 0; gap: 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index fe8b7ff5c7..1c50eb8167 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1,6 +1,7 @@ /** * The in-app workspace-directory browser (figma Harness 813-23126 family): a - * fixed 600×420 dialog whose header carries the title, the selection-path + * 600×420 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 @@ -364,6 +365,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // 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() } }} /> diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index a590bd3b17..5221e627f8 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -177,6 +177,27 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { + // The initial home listing hangs; Edit Path supersedes it while parent + // is still null, and Escape must not strand a blank picker. + let settled = false + const gate = new Promise(() => {}) + const listDirectory = vi.fn(async (path?: string) => { + if (!settled) { settled = true; return gate } + return listingFor(path) + }) + mount({ listDirectory }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe('') + fireEvent.keyDown(input, { key: 'Escape' }) + // Cancellation relaunched the home listing instead of leaving neither + // rows nor status behind. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + expect(listDirectory).toHaveBeenCalledTimes(2) + expect(listDirectory).toHaveBeenLastCalledWith(undefined) + }) + it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From baaa53523542c1f3247e3f8277f0ed57f9bdf20b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:05:36 +0800 Subject: [PATCH 087/153] fix(host): race directory reads against the caller's signal; report aborts as cancelled Every filesystem await in the browse scan (opendir and each read) now races the signal through raceAbort, so a stalled network open/read stops with a departed caller and an already-aborted request rejects even for an empty level; the abandoned settlement is swallowed and an abandoned open that still mints a handle is closed, never leaked. apiproxy maps an aborted listing to the cancelled wire code, matching pickDirectory and command.execute, instead of reporting a false internal failure. The fixture spec call sites gain the wire signal argument the previous commit's static lane flagged. --- docs/config-catalog.md | 2 +- .../client/connection/tests/fixture.spec.ts | 4 +- packages/host/apiproxy/src/api-proxy.ts | 5 ++ .../tests/api-proxy-workspace.spec.ts | 14 ++++ .../host/directory-picker-browse/src/index.ts | 82 ++++++++++++++++--- .../tests/service.spec.ts | 39 ++++++++- 6 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3e70c22fbc..42eb9c2325 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:127`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:170`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 3c8256ac3e..15fecad404 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -320,13 +320,13 @@ describe('createFixtureApi', () => { const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' })) + const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' })) + const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) if (!root.result.ok) throw new Error('root list failed') expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d4c4268088..a2b2a887cd 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1090,6 +1090,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // stops the backend's directory scan instead of outliving it. return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { + // An abort is the caller's own timeout/disconnect, not a server + // failure — same code pickDirectory and command.execute report. + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) + } return err(request, directoryError(error)) } }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f04850c34d..3968cba5c7 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -193,6 +193,20 @@ describe('host.listDirectory / host.createDirectory', () => { }) }) + it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => { + const { api } = await harness(undefined, { + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = api.host.listDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index be27c5a2d4..dbb3a03a5b 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -95,6 +95,49 @@ export function boundedInsert(window: ListingCandidate[], candidate: ListingCand return true } +/** + * Await `operation`, but reject with the signal's reason the moment it + * aborts. Node's filesystem reads are not retractable, so the operation + * itself keeps running against a handle the caller then closes — its late + * settlement is swallowed here so an abandoned read cannot surface as an + * unhandled rejection. + * @param operation - the in-flight filesystem step. + * @param signal - caller lifetime; absent means plain awaiting. + * @returns the operation's value. + */ +export function raceAbort(operation: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return operation + return new Promise((resolve, reject) => { + const onAbort = (): void => { + operation.catch(() => { + // Abandoned read: its handle is being closed by the aborting caller, + // and the abort reason already carried the outcome. + }) + reject(asError(signal.reason)) + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (reason: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(asError(reason)) + }, + ) + }) +} + +/** The thrown value as an Error (wire/abort reasons may be anything). */ +function asError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(String(reason)) +} + /** Message text of an unknown thrown value. */ function messageOf(error: unknown): string { /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ @@ -180,16 +223,35 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const window: ListingCandidate[] = [] let evicted = false try { - const level = await opendir(target) - for await (const dirent of level) { - // A disconnected/timed-out caller stops the scan here; throwing out - // of the loop closes the directory handle via the iterator's return. - signal?.throwIfAborted() - // Only rows a browser could enter contend for the window; dirent - // says "directory" outright, a symlink needs the later stat probe. - if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue - const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } - if (boundedInsert(window, candidate, keep)) evicted = true + // Every filesystem await races the caller's signal: a stalled + // opendir/read on a network filesystem must not keep a departed + // caller's scan alive, and an already-aborted request rejects even + // when the level is empty. + const opening = opendir(target) + const level = await raceAbort(opening, signal).catch((error: unknown) => { + // The abandoned open can still mint a handle after the abort won; + // close it so a departed caller cannot leak a descriptor. (A lost + // race against opendir's own rejection has nothing to close.) + void opening.then(async (dir) => { await dir.close() }, () => { + // Already rejected: raceAbort surfaced or swallowed it. + }) + throw error + }) + try { + for (;;) { + const dirent = await raceAbort(level.read(), signal) + if (dirent === null) break + // Only rows a browser could enter contend for the window; dirent + // says "directory" outright, a symlink needs the later stat probe. + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue + const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } + if (boundedInsert(window, candidate, keep)) evicted = true + } + } finally { + // Manual read() never auto-closes; close on every exit, the aborted + // one included (its abandoned read settles against the closed handle + // and raceAbort already swallowed that settlement). + await level.close() } } catch (error: unknown) { // An abort is the caller's own reason, not an unreadable directory. diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 99366e8754..835948d9fe 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' -import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts' import type { ListingCandidate } from '../src/index.ts' let root: string @@ -86,8 +86,15 @@ describe('BrowseDirectoryPicker', () => { it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { const gone = new AbortController() gone.abort(new Error('caller left')) - // The abort surfaces as-is, not dressed as an unreadable directory. + // The abort surfaces as-is, not dressed as an unreadable directory — + // and rejects even before any level row is read. await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // The abandoned open that still succeeds is closed, not leaked. + await new Promise(resolve => setTimeout(resolve, 10)) + // Aborted against a missing target: the abandoned open rejects on its + // own and there is nothing to close. + await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left') + await new Promise(resolve => setTimeout(resolve, 10)) // A live signal changes nothing about ordinary failures. const live = new AbortController() const missing = join(root, 'no-such-dir') @@ -96,6 +103,34 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') }) + it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => { + // No signal / settled operations: plain passthrough, listener removed. + await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok') + const live = new AbortController() + await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok') + // Failure passthrough keeps the operation's own error. + await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure') + // The abort wins over a pending operation and carries its own reason; + // the operation's late rejection is swallowed, never unhandled. + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + let rejectLate!: (reason: unknown) => void + const pending = new Promise((_resolve, reject) => { rejectLate = reject }) + const controller = new AbortController() + const raced = raceAbort(pending, controller.signal) + // A bare-string abort reason exercises the Error wrap. + controller.abort('caller left') + await expect(raced).rejects.toThrow('caller left') + rejectLate(new Error('late read failure')) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(rejections).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) const window: ListingCandidate[] = [] From e89ae129d76f59092c1cc25905e658d822f866ac Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:16:11 +0800 Subject: [PATCH 088/153] fix(host): wrap the dialog footer on narrow viewports The confirm/cancel pair wraps onto its own row when the viewport-clamped card is too narrow for the whole footer, so Open stays visible instead of clipping past the card's hidden overflow. --- .../src/client/DirectoryBrowser.module.css | 3 +++ 1 file changed, 3 insertions(+) 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 82be42efbe..800af854a1 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -233,6 +233,9 @@ .footerBar { display: flex; align-items: center; + /* Narrow viewports wrap the confirm/cancel pair onto their own row + * instead of clipping Open past the card's hidden overflow. */ + flex-wrap: wrap; gap: 8px; flex: none; padding: 12px 24px 28px; From 7d07ab0c9dc2983c7476d7648b30289dfaee6a38 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:32:12 +0800 Subject: [PATCH 089/153] fix(host): abandon close behind a stalled read; race symlink probes; observe cleanup failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aborted exit no longer awaits close (Node queues it behind any in-flight read, chaining the departed caller back onto the very stall the abort escaped) — the abandoned close's failure is swallowed, it has no consumer. Symlink stat probes race the signal too, with a per-candidate abort check between probes, so a stalled probe target cannot keep a departed request alive. The deferred handle cleanup after a lost opendir race now consumes its own close failure instead of leaking it as an unhandled rejection. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- docs/config-catalog.md | 2 +- .../host/directory-picker-browse/src/index.ts | 42 +++++++++++++++---- .../tests/service.spec.ts | 7 +++- 6 files changed, 44 insertions(+), 15 deletions(-) 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 5696f35720..cd1441f46f 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: 9d885abe394cfd30a174f07f6ac9432b774188b6 -2026-07-28-directory-picker-capability-seam.zh.md: 6dd1b66b508f5f06d6a150b6c45f0da7d36bf2ce +2026-07-28-directory-picker-capability-seam.md: 9f2703b7499870bf2d5ff8735e3c8cb2c351a503 +2026-07-28-directory-picker-capability-seam.zh.md: 946156a5d06cea30c833c768147b36b30f258192 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 9d885abe39..9f2703b749 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 @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **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. - **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. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **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. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 6dd1b66b50..946156a5d0 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 @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 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 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `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 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 42eb9c2325..075597d502 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:170`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index dbb3a03a5b..6fa157ea87 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -138,6 +138,11 @@ function asError(reason: unknown): Error { return reason instanceof Error ? reason : new Error(String(reason)) } +/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */ +/** Swallow the close failure of a handle its caller already departed. */ +function swallowCloseFailure(): void {} +/* v8 ignore stop */ + /** Message text of an unknown thrown value. */ function messageOf(error: unknown): string { /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */ @@ -149,13 +154,19 @@ function messageOf(error: unknown): string { * non-directories and broken/cyclic links (skipped silently — the browser * shows what can be entered, and a broken link cannot). */ -async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise { +async function directoryRow( + parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean, signal: AbortSignal | undefined, +): Promise { const path = join(parent, name) let enterable = isDirectory if (!enterable && isSymbolicLink) { try { - enterable = (await stat(path)).isDirectory() + // The probe races the caller too: a symlink target on a stalled + // network filesystem must not keep a departed caller's request alive. + enterable = (await raceAbort(stat(path), signal)).isDirectory() } catch { + /* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */ + if (signal?.aborted) throw asError(signal.reason) // Broken or cyclic symlink: stat is the probe, failure means "not enterable". return null } @@ -231,8 +242,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const level = await raceAbort(opening, signal).catch((error: unknown) => { // The abandoned open can still mint a handle after the abort won; // close it so a departed caller cannot leak a descriptor. (A lost - // race against opendir's own rejection has nothing to close.) - void opening.then(async (dir) => { await dir.close() }, () => { + // race against opendir's own rejection has nothing to close, and + // the close's own failure is swallowed — the request already + // returned, so a cleanup error has no consumer.) + void opening.then(dir => dir.close().catch(swallowCloseFailure), () => { // Already rejected: raceAbort surfaced or swallowed it. }) throw error @@ -248,10 +261,18 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (boundedInsert(window, candidate, keep)) evicted = true } } finally { - // Manual read() never auto-closes; close on every exit, the aborted - // one included (its abandoned read settles against the closed handle - // and raceAbort already swallowed that settlement). - await level.close() + // Manual read() never auto-closes; close on every exit. The aborted + // exit must not await it — Node queues close behind any in-flight + // read, so awaiting would chain the departed caller back onto the + // very stall the abort escaped (the abandoned read's settlement is + // already swallowed by raceAbort). + const closing = level.close() + /* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */ + if (signal?.aborted) { + closing.catch(swallowCloseFailure) + } else { + await closing + } } } catch (error: unknown) { // An abort is the caller's own reason, not an unreadable directory. @@ -261,7 +282,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const entries: DirectoryEntry[] = [] let truncated = evicted for (const candidate of window) { - const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink) + // A caller that departed between reads and probes stops before the + // next probe (each probe's own await is raced inside directoryRow). + signal?.throwIfAborted() + const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal) if (row === null) continue if (entries.length === this.config.maxEntries) { truncated = true diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 835948d9fe..002d42e516 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -95,8 +95,13 @@ describe('BrowseDirectoryPicker', () => { // own and there is nothing to close. await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left') await new Promise(resolve => setTimeout(resolve, 10)) - // A live signal changes nothing about ordinary failures. + // A live signal leaves a normal listing untouched — the reads and the + // symlink probes race it without ever losing. const live = new AbortController() + const complete = await capability.list(root, live.signal) + expect(complete.truncated).toBe(false) + expect(complete.entries.map(entry => entry.name)).toContain('linked') + // A live signal changes nothing about ordinary failures. const missing = join(root, 'no-such-dir') const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) expect(failure).toBeInstanceOf(DirectoryPickerError) From 723bb9057cb840378e099a459796b210cf35996c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:49:18 +0800 Subject: [PATCH 090/153] fix(host): pass the entered path to the Host untrimmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim now only detects a blank draft; the original text navigates — a real directory name may end in whitespace, and trimming would list its sibling or adopt the wrong workspace. --- .../src/client/DirectoryBrowser.tsx | 6 ++++-- .../tests/directory-browser.spec.tsx | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 1c50eb8167..db2231f384 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -347,8 +347,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onKeyDown={(event) => { if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() - const target = pathDraft.trim() - if (target !== '') navigate(target) + // Trim only detects a blank draft; the Host gets the + // original text — a real directory name may end in + // whitespace, and trimming would list its sibling. + if (pathDraft.trim() !== '') navigate(pathDraft) } if (event.key === 'Escape') { event.stopPropagation() diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 5221e627f8..dd7b7c0ae2 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -198,6 +198,18 @@ describe('DirectoryBrowser', () => { expect(listDirectory).toHaveBeenLastCalledWith(undefined) }) + it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { + const listDirectory = vi.fn(async (path?: string) => listingFor(path)) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS} ` } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may name a real directory; trimming would list its sibling. + await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `) }) + }) + it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 463893947bf7174532cefe8658e0cf2e73d6ee33 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:59:51 +0800 Subject: [PATCH 091/153] fix(host): create folders with the entered name untrimmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same contract as the path editor: trim only rejects an all-whitespace draft, and the Host receives the original spelling — the backend accepts any non-blank single segment verbatim, so trimming here would create and select a different sibling. --- .../src/client/DirectoryBrowser.tsx | 7 +++++-- .../tests/directory-browser.spec.tsx | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index db2231f384..1705a42e2a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -211,8 +211,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const confirmCreate = (): void => { /* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */ if (targetPath === null || folderDraft === null || creatingFolder) return - const name = folderDraft.trim() - if (name === '') return + // Trim only rejects an all-whitespace draft; the Host gets the original + // spelling — the backend accepts any non-blank single segment verbatim, + // and trimming here would create (and select) a different sibling. + const name = folderDraft + if (name.trim() === '') return setCreatingFolder(true) setCreateError(null) const generation = openGeneration.current diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index dd7b7c0ae2..f0c54ad421 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -542,6 +542,17 @@ describe('DirectoryBrowser', () => { expect(screen.getByText('Documents')).toBeTruthy() }) + it('passes the folder name to the Host untrimmed (trim only gates blank drafts)', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + const input = screen.getByLabelText('browser.folderName') + fireEvent.change(input, { target: { value: 'project ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + // A trailing space may be the wanted spelling; trimming would create a sibling. + await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'project ') }) + }) + it('creates a folder through the nested dialog and lands with it selected', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 53240f4664575c1a214bd1ec222f36d5073cd461 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 06:19:18 +0800 Subject: [PATCH 092/153] fix(host,client): abort superseded listings on the wire; keep the native swap resolvable Supersession (newer navigation, path editing, closing, unmount) now aborts the in-flight listing's request instead of only discarding its result: the browser mints an AbortController per listing, the signal rides the workspace face (IWorkspaces.listDirectory gains an optional signal) onto the fetch carrier, and the Host scan stops with it (817's cancellation chain). apps/cli keeps both picker packages as dependencies so the documented one-row cordis.yml swap to the native backend resolves at boot. --- apps/cli/package.json | 1 + .../runtime/src/client/contract/workspaces.ts | 3 +- .../runtime/src/client/workspaces/service.ts | 5 +- .../client/test-runtime/src/workspaces.ts | 2 +- .../src/client/DirectoryBrowser.tsx | 50 +++++++++++++------ .../src/client/flow.ts | 4 +- .../src/client/index.ts | 2 +- .../tests/directory-browser.spec.tsx | 35 ++++++++++--- pnpm-lock.yaml | 3 ++ 9 files changed, 77 insertions(+), 28 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index db4c0c6173..36f1f15424 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-fs-policy": "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-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index bb50b43277..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -40,9 +40,10 @@ export interface IWorkspaces { /** * List one directory level through the Host's `browse` capability. * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. * @returns the level's listing with breadcrumb ancestry. */ - listDirectory(path?: string): Promise + listDirectory(path?: string, signal?: AbortSignal): Promise /** * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index acc0d079aa..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces { /** * List one directory level through the Host's `browse` capability. * @param path - absolute directory to list; absent lists the Host home directory. + * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it. * @returns the level's listing with breadcrumb ancestry. */ - async listDirectory(path?: string): Promise { - const response = await this.api.host.listDirectory(path === undefined ? {} : { path }) + async listDirectory(path?: string, signal?: AbortSignal): Promise { + const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal) if (!response.result.ok) throw new DirectoryBrowseError(response.result.error) return response.result.value } diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index b4d91a96b9..3505de0853 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -115,7 +115,7 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute directory to list; absent lists the home level. * @returns the level's listing. */ - async listDirectory(path?: string): Promise { + async listDirectory(path?: string, _signal?: AbortSignal): Promise { this.calls.push({ method: 'listDirectory', args: [path] }) const stub = this.stubs.get('listDirectory') if (stub !== undefined) return await (stub(path) as Promise) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 1705a42e2a..f348f1dd8d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -27,8 +27,8 @@ import css from './DirectoryBrowser.module.css' export interface DirectoryBrowserProps { /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean - /** List one directory level (absent path = the Host home directory). */ - listDirectory: (path?: string) => Promise + /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ + listDirectory: (path?: string, signal?: AbortSignal) => Promise /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ @@ -115,6 +115,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [creatingFolder, setCreatingFolder] = useState(false) const [createError, setCreateError] = useState(null) const requestSeq = useRef(0) + // The in-flight listing's controller: superseding intent aborts the wire + // request too — the Host stops scanning — instead of only discarding the + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -130,18 +134,34 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 + scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, onCompositionEnd: () => { composingRef.current = false }, } + /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ + const supersede = useCallback((): number => { + scanController.current?.abort() + scanController.current = null + return ++requestSeq.current + }, []) + + /** Launch one listing under a fresh controller so a later supersession can abort it. */ + const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise } => { + const seq = supersede() + const controller = new AbortController() + scanController.current = controller + return { seq, scan: listDirectory(path, controller.signal) } + }, [supersede, listDirectory]) + /** Replace the whole view with one freshly listed level (no selection). */ const navigate = useCallback((path?: string) => { - const seq = ++requestSeq.current + const { seq, scan } = launchListing(path) setLoading(true) setError(null) - listDirectory(path).then((next) => { + scan.then((next) => { if (seq !== requestSeq.current) return setParent(next) setSelected(null) @@ -153,16 +173,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) setError(failureText(reason)) }) - }, [listDirectory]) + }, [launchListing]) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { - const seq = ++requestSeq.current + const { seq, scan } = launchListing(entry.path) setSelected(entry) setChild(null) setLoading(true) setError(null) - listDirectory(entry.path).then((next) => { + scan.then((next) => { if (seq !== requestSeq.current) return setChild(next) setLoading(false) @@ -174,7 +194,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // breadcrumb still names the level: fall back to the single pane. setSelected(null) }) - }, [listDirectory]) + }, [launchListing]) /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { @@ -196,12 +216,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, navigate() return } - requestSeq.current += 1 + supersede() setError(null) setPathDraft(null) setFolderDraft(null) setCreateError(null) - }, [open, navigate]) + }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ const targetPath = selected?.path ?? parent?.path ?? null @@ -227,9 +247,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. - const seq = ++requestSeq.current + const { seq, scan } = launchListing(targetPath) setLoading(true) - listDirectory(targetPath).then((level) => { + scan.then((level) => { /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) @@ -324,7 +344,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Opening the editor supersedes any pending listing: a // settlement landing before the first keystroke would // otherwise close the editor via navigate's draft reset. - requestSeq.current += 1 + supersede() setLoading(false) setPathDraft(selected?.path ?? parent?.path ?? '') }} @@ -342,7 +362,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Editing the draft supersedes any in-flight navigation: // its completion must neither clear the newer text nor // repopulate the view with the older path. - requestSeq.current += 1 + supersede() setLoading(false) setPathDraft(event.target.value) }} @@ -361,7 +381,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // launched: its late success must not jump to the // cancelled path, so the pending request is superseded // and the view leaves the loading state. - requestSeq.current += 1 + supersede() setLoading(false) setPathDraft(null) setError(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 662c40de62..84e49b2c98 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -13,8 +13,8 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' /** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */ export interface BrowseFlowInjected { - /** List one directory level (absent path = the Host home directory). */ - listDirectory: (path?: string) => Promise + /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ + listDirectory: (path?: string, signal?: AbortSignal) => Promise /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index 6eb43829db..8ec0ffc5f9 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { }, 'directory-picker-browse: dialog dictionaries') const injected = (): BrowseFlowInjected => ({ - listDirectory: path => ctx.workspaces.listDirectory(path), + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name), t: ctx.locale.bind(LOCALE_NS), }) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index f0c54ad421..babe7c8bf7 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -95,7 +95,7 @@ describe('DirectoryBrowser', () => { it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(b.listDirectory).toHaveBeenCalledWith(undefined) + expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal)) expect(columns()).toHaveLength(1) expect(screen.getByRole('listitem').textContent).toBe('Documents') expect(screen.queryByText('.config')).toBeNull() @@ -113,7 +113,7 @@ describe('DirectoryBrowser', () => { expect(selectedRow.textContent).toBe('Documents') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') expect(within(preview!).getByRole('listitem').textContent).toBe('harness') - expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) + expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy() }) @@ -130,6 +130,29 @@ describe('DirectoryBrowser', () => { expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true') }) + it('aborts a superseded listing on the wire, and the in-flight one on close', async () => { + const signals: (AbortSignal | undefined)[] = [] + const gates: (() => void)[] = [] + const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (signals.length === 1) return Promise.resolve(listingFor(path)) + // Later listings hang until released: supersession must abort them + // on the wire, not merely discard their eventual results. + return new Promise((resolve) => { gates.push(() => { resolve(listingFor(path)) }) }) + }) + const b = mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + expect(signals).toHaveLength(2) + // A crumb jump supersedes the hanging preview: its request aborts. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[1]?.aborted).toBe(true) + expect(signals[2]?.aborted).toBe(false) + // Closing the dialog aborts the still-pending navigation too. + b.view.rerender() + expect(signals[2]?.aborted).toBe(true) + }) + it('jumps back through a crumb into a fresh single-column level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -195,7 +218,7 @@ describe('DirectoryBrowser', () => { // rows nor status behind. await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) expect(listDirectory).toHaveBeenCalledTimes(2) - expect(listDirectory).toHaveBeenLastCalledWith(undefined) + expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) }) it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => { @@ -207,7 +230,7 @@ describe('DirectoryBrowser', () => { fireEvent.change(input, { target: { value: `${DOCS} ` } }) fireEvent.keyDown(input, { key: 'Enter' }) // A trailing space may name a real directory; trimming would list its sibling. - await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `) }) + await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) }) }) it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => { @@ -347,7 +370,7 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory.mock.calls.length).toBe(listCalls) fireEvent.compositionEnd(pathInput) fireEvent.keyDown(pathInput, { key: 'Enter' }) - await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) }) + await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) }) // Create dialog: same guard. fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) const nameInput = screen.getByLabelText('browser.folderName') @@ -764,6 +787,6 @@ describe('DirectoryBrowser', () => { b.view.rerender() await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) expect(columns()).toHaveLength(1) - expect(b.listDirectory).toHaveBeenLastCalledWith(undefined) + expect(b.listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal)) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7026700fa..bc013a08a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-browse': specifier: workspace:^ version: link:../../packages/host/directory-picker-browse + '@deepseek-ai/dsh-host-directory-picker-native': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver From d80fca0db888b3837a5983a34292768da51a9674 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 06:31:18 +0800 Subject: [PATCH 093/153] fix(client): forward the abort signal through the workspaces test double TestWorkspaces.listDirectory now records the signal and passes it to the installed stub, mirroring the production face, so cancellation integration tests can observe or reject on a superseded scan instead of the harness silently dropping it. --- packages/client/test-runtime/src/workspaces.ts | 9 ++++++--- .../client/test-runtime/tests/runtime.spec.tsx | 14 ++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 3505de0853..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -115,10 +115,13 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute directory to list; absent lists the home level. * @returns the level's listing. */ - async listDirectory(path?: string, _signal?: AbortSignal): Promise { - this.calls.push({ method: 'listDirectory', args: [path] }) + async listDirectory(path?: string, signal?: AbortSignal): Promise { + // The signal is recorded and forwarded like the production face passes + // it to the wire, so cancellation integration tests can observe or + // reject on a superseded scan. + this.calls.push({ method: 'listDirectory', args: [path, signal] }) const stub = this.stubs.get('listDirectory') - if (stub !== undefined) return await (stub(path) as Promise) + if (stub !== undefined) return await (stub(path, signal) as Promise) // The chain runs root-to-target inclusive, per the DirectoryListing // contract — a bare root crumb would mislabel the level in browsers // driven by this double. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index a6f22a85fe..b170f69ba2 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,16 +329,22 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // The recorded signal seat mirrors the production face (undefined here; + // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ - { method: 'listDirectory', args: [undefined] }, - { method: 'listDirectory', args: ['/home/test'] }, + { method: 'listDirectory', args: [undefined, undefined] }, + { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } - runtime.workspaces.stub('listDirectory', vi.fn(() => Promise.resolve(listing as never))) + const listStub = vi.fn(() => Promise.resolve(listing as never)) + runtime.workspaces.stub('listDirectory', listStub) runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never))) - await expect(runtime.workspaces.listDirectory('/x')).resolves.toBe(listing) + const scan = new AbortController() + await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing) + // The stub receives the signal too, like the production face gives the wire. + expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal) await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made') await runtime.dispose() }) From 32774d5ff367b34b57b11eb51f7cf0986ee7e707 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 09:29:43 +0800 Subject: [PATCH 094/153] Update AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 93299d01e4..1c0b76404d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Use incremental merge commits.** Split independent changes. Pushed PR history may be rewritten before merge. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). +- **Use incremental merge commits.** Split independent changes. never squash, rebase, or rewrite pushed history unless it's an unmerged PR. Fix the introducing PR before merging down-stack. If the base advances mid-merge, never restart: finish the checkpoint, push when authorized, then merge the newer tip separately ([rationale](.agents/notes/implemented/process/2026-07-26-incremental-pr-base-retargeting.md)). - **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 31cb340c367abf5f33dfefb3787c6eeb2aaf749b Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 10:00:29 +0800 Subject: [PATCH 095/153] feat(tui)!: consolidate terminal UI improvements --- packages/bash/tool-bash/README.i18n.yaml | 6 +- packages/bash/tool-bash/README.md | 4 +- packages/bash/tool-bash/README.zh.md | 4 +- packages/bash/tool-bash/src/index.ts | 4 +- packages/bash/tool-bash/src/render.ts | 31 +- packages/bash/tool-bash/tests/tools.spec.ts | 30 +- packages/ui/tui/AGENTS.md | 5 + packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 23 +- packages/ui/tui/README.zh.md | 25 +- packages/ui/tui/src/chat/resume.ts | 82 ++- packages/ui/tui/src/chat/timing.ts | 2 +- packages/ui/tui/src/components/dialogs.ts | 175 +++++-- packages/ui/tui/src/components/theme.ts | 232 +++++++-- packages/ui/tui/src/components/transcript.ts | 211 ++++++-- .../ui/tui/src/components/xml-tool-output.ts | 40 +- packages/ui/tui/src/config.ts | 14 +- packages/ui/tui/src/extension/types.ts | 4 +- packages/ui/tui/src/index.ts | 199 ++++--- packages/ui/tui/src/runtime.ts | 22 +- packages/ui/tui/tests/harness.ts | 3 + .../advanced-cards-collapsed.expected.txt | 25 +- .../advanced-cards-expanded.expected.txt | 37 +- .../snapshots/banner-gradient.expected.txt | 16 +- .../snapshots/code-mode-pending.expected.txt | 18 +- .../conversation-streaming.expected.txt | 25 +- .../cordis-tools-pending.expected.txt | 18 +- .../snapshots/disposed-terminal.expected.txt | 104 ++-- .../dynamic-workflow-pending.expected.txt | 18 +- .../snapshots/errors-and-help.expected.txt | 102 ++-- .../snapshots/file-autocomplete.expected.txt | 20 +- .../model-selector-filtered.expected.txt | 52 ++ .../snapshots/model-selector.expected.txt | 69 +-- .../snapshots/model-switching.expected.txt | 20 +- ...question-dialog-single-option.expected.txt | 22 +- .../question-dialog-validation.expected.txt | 14 +- .../snapshots/question-dialog.expected.txt | 14 +- ...esume-sessions-all-workspaces.expected.txt | 57 ++ .../snapshots/resume-sessions.expected.txt | 17 +- .../snapshots/retry-cancelled.expected.txt | 20 +- .../snapshots/retry-exhausted.expected.txt | 20 +- .../snapshots/retry-recovered.expected.txt | 20 +- .../snapshots/retry-scheduled.expected.txt | 20 +- .../snapshots/session-reference.expected.txt | 16 +- .../session-title-autocomplete.expected.txt | 20 +- .../shell-prompt-multiline.expected.txt | 10 +- .../status-diagnostics-narrow.expected.txt | 48 +- .../snapshots/status-diagnostics.expected.txt | 50 +- .../step-timing-completed.expected.txt | 22 +- ...rface-after-compaction-narrow.expected.txt | 39 +- ...surface-after-compaction-wide.expected.txt | 40 +- .../surface-before-compaction.expected.txt | 25 +- .../snapshots/todo-plan-cleared.expected.txt | 22 +- .../snapshots/untrusted-controls.expected.txt | 35 +- packages/ui/tui/tests/tui.snapshot.ts | 66 ++- packages/ui/tui/tests/tui.spec.ts | 485 +++++++++++++++--- packages/ui/tui/tests/xml-tool-output.spec.ts | 45 +- 57 files changed, 1875 insertions(+), 896 deletions(-) create mode 100644 packages/ui/tui/AGENTS.md create mode 100644 packages/ui/tui/tests/snapshots/model-selector-filtered.expected.txt create mode 100644 packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt diff --git a/packages/bash/tool-bash/README.i18n.yaml b/packages/bash/tool-bash/README.i18n.yaml index a529b56b56..676c935cbd 100644 --- a/packages/bash/tool-bash/README.i18n.yaml +++ b/packages/bash/tool-bash/README.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 -README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46 -README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06 +# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md +README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92 +README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 965ae25a5e..deb6b899c8 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe. ## The tool builds its request from named args only @@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual. +- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual. - **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md). - **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters. diff --git a/packages/bash/tool-bash/README.zh.md b/packages/bash/tool-bash/README.zh.md index 60be5c5ca5..c2514308fb 100644 --- a/packages/bash/tool-bash/README.zh.md +++ b/packages/bash/tool-bash/README.zh.md @@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques ## UI 展示 -工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 +工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片,包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id,因此使用通用执行卡片;通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。 ## 工具仅使用具名参数构建请求 @@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr ## 已知限制与延期工作 -- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill;这是仅影响展示的已知残留问题。 +- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。 - **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。 - **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。 diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index b403c4414e..f8cece2862 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView | if (isBackground || result.isError) { return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } } - return { card: 'terminal', output: raw, ...parseExitStatus(raw) } + // The exit marker becomes the card's exit pill, so it leaves the output body. + const { body, ...exit } = parseExitStatus(raw) + return { card: 'terminal', output: body, ...exit } } /** diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index 77a88e28f3..eabe681c25 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -95,10 +95,23 @@ export function renderProcessRead( } /** - * Recover the structured exit status from a rendered {@link renderResult} - * string — the inverse of the status markers it appends. A killed marker - * yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both - * means a clean exit 0. + * The exit status recovered from a rendered result, with the output body that + * status was split off from. + */ +export type ParsedExitStatus = + & { body: string } + & ({ exitCode: number } | { signal: string }) + +/** + * Split a rendered {@link renderResult} string into its output body and the + * structured exit status — the inverse of the status markers it appends. A + * killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`; + * absent both means a clean exit 0. + * + * The consumed marker is removed from `body` because a terminal presentation + * shows the exit status as its own pill: leaving the marker in the output would + * render the exit twice. Other markers (timeout, sandbox denial) carry facts no + * pill shows, so they stay in the body. * * Replay only retains the rendered content text, not the original * `BashRunResult`, so terminal presentation must recover the exit pill here. @@ -106,12 +119,12 @@ export function renderProcessRead( * that merely ends with marker-like text from matching unless the final line * is indistinguishable from a real marker. * @param text - rendered model-facing bash result. - * @returns the recovered terminal exit code or signal. + * @returns the marker-free body plus the recovered terminal exit code or signal. */ -export function parseExitStatus(text: string): { exitCode: number } | { signal: string } { +export function parseExitStatus(text: string): ParsedExitStatus { const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { signal: signal[1] } + if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) } - return { exitCode: 0 } + if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { body: text, exitCode: 0 } } diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 15222ee647..a52b3f16f1 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( - { command: 'echo hi', description: 'echo' }, - { content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false }, + { command: 'printf "hi\\n\\n"', description: 'echo' }, + // A clean run renders no exit marker at all, so the body is the raw bytes. + { content: [{ type: 'text', text: 'hi\n\n' }], isError: false }, ) // A terminal result keeps the RAW bytes (newlines intact) a terminal renderer - // needs; the bridge derives the fenced fallback. exitCode is parsed back from - // the [exit code: N] marker. - expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 }) + // needs; the bridge derives the fenced fallback. + expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 }) }) it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { const ctx = await setup() const args = { command: 'x', description: 'x' } const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }) - expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 }) + expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 }) const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }) - expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' }) + expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' }) + }) + + it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => { + const ctx = await setup() + const args = { command: 'x', description: 'x' } + const timedOut = ctx.tools.get('bash')!.presentResult!( + args, + { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false }, + ) + expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 }) }) it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => { @@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { const rendered = renderResult(c.result) const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) // Drop card + output; the remaining fields are the parsed exit. - const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } + const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } expect(exit).toEqual(c.expect) + // Whatever the parse consumed is gone from the body, so a card with an exit + // pill never shows the same status twice. + expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /) } }) @@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) + // Unparsed marker-like text is real output, so it is NOT stripped from the body. // Same for a fake signal marker with no leading newline. const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) diff --git a/packages/ui/tui/AGENTS.md b/packages/ui/tui/AGENTS.md new file mode 100644 index 0000000000..2e73212a58 --- /dev/null +++ b/packages/ui/tui/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md — TUI package + +These rules supplement the package conventions in [packages/AGENTS.md](../../AGENTS.md). + +- **Present TUI designs in tmux, not in the session transcript.** When tmux is available, run the assembled TUI in a pane of the same window the session runs in and point the user at it; print a rendering into the transcript only as a fallback. diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 4e0757170d..8ab63910fa 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/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/ui/tui/README.md -README.md: 5aafd6f5207320bf273c96a04f2d606577ca2da0 -README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5 +README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d +README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 5aafd6f520..0b358520b8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -22,9 +22,9 @@ Typing `@` at a token boundary searches files and directories under the session When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook. -While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. +While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/palette`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool and injected-context cards collapse long bodies into a configurable head/tail preview; Ctrl+O cycles tool cards through collapsed preview, full output, and hidden — the hidden phase drops tool cards from the transcript entirely while context cards stay at their preview, since injected instructions are not tool traffic. An injected-context card renders its message as prose with the producer's outer reminder frame stripped, so neither the fold nor the frame stripping depends on the payload's syntax. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. -`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: a filter box above the list narrows rows by a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the highlighted row selected when it survives the filter; Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape clears a non-empty filter before a second Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. @@ -32,9 +32,15 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a full-viewport keyboard selector instead of a centered dialog. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. -`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. +Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. + +Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. + +The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch. + +A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice. ## Config @@ -57,7 +63,6 @@ The footer sums the session's reported usage as `↑ | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | -| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id | ```yaml - id: terminal @@ -74,7 +79,11 @@ Startup fails before mounting when either process stream is not a TTY. The compo ## Color -The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. +Every SGR code the TUI emits lives in one table, `paletteSpec` in `components/theme.ts`, which `createPalette` derives its wrappers from and `/palette` prints; no component writes an escape of its own. The table holds only the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so the TUI stays readable on light and dark backgrounds alike — the startup banner's brand gradient is the one deliberate exception. Body text keeps the terminal's default foreground rather than a fixed shade. + +There is one role per visual meaning: `dim` is the single recessed tone and `accent` the single emphasis color, while `success` and `error` double as a diff's added and removed lines. Colors and attributes are separately typed, so `bold(accent(x))` compiles and `accent(error(x))` does not — SGR has no color stack, so nesting one color inside another silently drops the outer color at the inner one's close. Attributes occupy independent SGR groups and compose with any color in either order. Run `/palette` to see every role as your terminal renders it, with its SGR pair. + +Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. Inside a tool card, the whole body — presenter title, a terminal `$` command and cwd, and the tool's own output — renders in one dim tone, so only the status-colored header carries color and the body reads as one recessed block instead of a run of competing shades; an injected-context card's prose is the same tone as its header. A diff card's `+`/`-` lines and a `[signal …]` marker stay colored, because there the color is the meaning rather than emphasis. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. ## Model Experience @@ -156,7 +165,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. +- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. The all-workspaces scope makes this reachable in one step, since a session another host is driving in a different directory is now selectable. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index 1901faeb26..7e89197bd8 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -22,9 +22,9 @@ TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reaso 挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。 -Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 +Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/palette`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片与注入上下文卡片都把长主体折叠为可配置的头尾预览;Ctrl+O 让工具卡片在折叠预览、完整输出、隐藏三种状态间循环——隐藏阶段把工具卡片从 transcript 中完全去掉,而上下文卡片保持预览,因为注入的指令不属于工具流量。注入上下文卡片把消息渲染为文本,并去掉生产方的外层提醒外框,因此折叠与去外框都不依赖载荷的语法。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。 -`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 +`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:列表上方设有一个过滤框,按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在高亮行仍通过过滤时保持其选中状态;Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 会先清除非空过滤内容,再次按下才关闭选择器。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model ` 仍可直接选择无歧义的模型 id,`/model /` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。 `/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill: [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。 @@ -32,9 +32,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。 -`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 +`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 -`resumeCommand` 仍是部署持有的回退行为:只有当前会话已持久化后,退出才会打印它;不支持原地 handoff 的宿主会显示所选会话的命令。`{session}` 展开为会话 id。TUI 代码绝不会执行模板或任意 shell 文本。 +获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 + +选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 + +退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 + +启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。 ## 配置 @@ -57,7 +63,6 @@ Footer 将会话报告的用量汇总为 `↑`;任 | `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor | | `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) | | `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 | -| `resumeCommand` | 未设置 | 供退出提示和不支持原地 handoff 的宿主使用的 shell 命令模板,其中 `{session}` 会展开为会话 id | ```yaml - id: terminal @@ -70,11 +75,15 @@ Footer 将会话报告的用量汇总为 `↑`;任 fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist'] ``` -任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose 会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。 +任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose(资源释放)会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。 ## 颜色 -Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 +TUI 发出的所有 SGR 代码都集中在一个表中,即 `components/theme.ts` 内的 `paletteSpec`;`createPalette` 从该表派生包装层,`/palette` 则打印该表,任何组件都不会自行写入转义序列。该表仅包含标准 16 色 ANSI 前景色和 SGR 属性;每个终端都会将它们重新映射到当前配色方案,因此 TUI 在浅色与深色背景下都保持可读——启动 banner 的品牌渐变是唯一一个有意保留的例外。正文使用终端默认前景色,而非固定色调。 + +每种视觉语义只对应一个角色:`dim` 是唯一的弱化色调,`accent` 是唯一的强调色,`success` 和 `error` 还分别充当 diff 的新增行与删除行。颜色和属性分属不同类型,因此 `bold(accent(x))` 可以通过编译,`accent(error(x))` 则不行——SGR 没有颜色栈;在一种颜色内嵌套另一种颜色时,内层颜色闭合时会静默丢弃外层颜色。各属性占用彼此独立的 SGR 组,可以按任一顺序与任何颜色组合。运行 `/palette` 可查看每个角色在你的终端上的实际渲染效果及其 SGR 码对。 + +成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。在工具卡片内部,整个正文——presenter 标题、终端 `$` 命令与 cwd,以及工具自身的输出——统一以同一种暗色渲染,因此只有带状态色的表头携带颜色,正文读作一个整体弱化的区块,而不是一串互相竞争的色调;注入上下文卡片的正文与其表头也是同一种色调。diff 卡片的 `+`/`-` 行与 `[signal …]` 标记保留颜色,因为那里的颜色本身就是语义,而非强调。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。 ## 模型体验 @@ -156,7 +165,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read ## 已知限制与延期工作 -- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。能够运行并发宿主的部署必须在 TUI 外协调所有权。 +- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。所有工作区作用域让这一情形一步即可触及,因为另一个宿主正在其他目录驱动的会话现在也可被选中。能够运行并发宿主的部署必须在 TUI 外协调所有权。 - **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。 - **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。 - **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 2f0fdd423c..9521bd7368 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,26 +1,23 @@ /** * Session-resume sub-controller for the interactive chat channel: the * `/resume` selector, per-candidate summary reads that tolerate a corrupt - * neighbor, the pre-handoff preflight, the terminal handoff itself, and the - * durable resume-hint command printed on exit. + * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ import type { TUI } from '@earendil-works/pi-tui' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionLogSnapshot, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { HintEditor } from './helpers.ts' import { formatCwd } from './helpers.ts' import type { TuiOverlaySession } from '../extension/types.ts' import type { TuiRuntime } from '../runtime.ts' -import type { Config } from '../config.ts' import { ResumePicker, summarizeResumeCandidate, @@ -31,9 +28,7 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts' /** Collaborators the resume controller needs from the chat channel. */ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { readonly agent: Agent - readonly config: Config readonly runtime: TuiRuntime - readonly persistence: SessionPersistence | undefined readonly sessionQuery: SessionQueryService | undefined readonly ui: TUI readonly editor: HintEditor @@ -43,47 +38,27 @@ export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { /** Session-resume controller for one chat channel. */ export interface ResumeController { - /** Open the current-workspace searchable session selector. */ + /** Open the searchable session selector, scoped to this workspace until the user widens it. */ showResume(): void - /** - * The resume command for the current session — the configured template with - * every `{session}` filled — but only once the session is durably persisted; - * `undefined` otherwise. - */ - currentResumeCommand(): Promise } /** * Build the session-resume controller for one chat channel. * @param deps - channel collaborators, terminal handles, and optional services. - * @returns the controller wired to the `/resume` command and exit hint. + * @returns the controller wired to the `/resume` command. */ export function createResumeController(deps: ResumeControllerDeps): ResumeController { const { - ctx, agent, config, runtime, resolved, palette, overlayManager, - persistence, sessionQuery, ui, editor, + ctx, agent, runtime, resolved, palette, overlayManager, + sessionQuery, ui, editor, } = deps let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false let resumeScan = 0 - /** - * Persisted sessions for this workspace, newest first. Empty when no - * persistence backend is mounted or a listing failure would otherwise block - * exit or crash `/resume`; the resume hint is best-effort convenience. - */ - const listWorkspaceSessions = async (): Promise => { - if (persistence === undefined) return [] - let all: readonly SessionHeader[] - try { - all = await persistence.list() - } catch { - // A listing failure must never block terminal exit or crash `/resume`. - return [] - } - return all - .filter(header => header.cwd === agent.session.header.cwd) - } + /** Label any session's own workspace the way the prompt labels the current one. */ + const workspaceLabel = (cwd: string | undefined): string => + runtime.formatCwd?.(cwd) ?? formatCwd(cwd) /** Build one display candidate without letting a corrupt neighbor abort the selector. */ const readResumeCandidate = async ( @@ -109,6 +84,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro agent.session.id, agent.session.header.cwd, providers, + workspaceLabel, ) } catch (error: unknown) { return { @@ -116,13 +92,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro title: 'Unreadable session', lastActivityAt: record.header.createdAt, lastTurn: 'log unavailable', + currentWorkspace: record.header.cwd === agent.session.header.cwd, + workspaceLabel: workspaceLabel(record.header.cwd), disabledReason: `session cannot be loaded: ${errorChain(error)}`, } } } - /** Re-read every mutable precondition immediately before terminal handoff. */ - const preflightResume = async (sessionId: SessionId): Promise => { + /** + * Re-read every mutable precondition immediately before terminal handoff and + * resolve the exact identity and workspace the host will re-exec into. + */ + const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') const initialStatus = deps.agentStatus() @@ -134,9 +115,12 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro new Set(ctx.llm.listProviders().map(provider => provider.id)), ) if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const cwd = candidate.record.header.cwd + /* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */ + if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`) const finalStatus = deps.agentStatus() if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return candidate + return { id: candidate.record.header.id, cwd } } const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { @@ -147,13 +131,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume if (hostHandoff === undefined) { - const template = config.resumeCommand - const fallback = template?.replaceAll('{session}', checked.record.header.id) await overlay.close() resumeOverlay = undefined - deps.appendNotice(fallback === undefined - ? 'Session is resumable, but this host cannot hand it off in place.' - : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') + deps.appendNotice('Session is resumable, but this host cannot hand it off in place.', 'warning') return } /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ @@ -169,7 +149,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro if (deps.isDisposed()) return ui.stop() terminalReleased = true - await hostHandoff(checked.record.header.id) + // The host re-execs into the session's own workspace: process cwd, not the + // restored session header, is what the filesystem and shell tools resolve + // against. + await hostHandoff(checked.id, checked.cwd) throw new Error('resume host returned without replacing the process') } catch (error: unknown) { if (!deps.isDisposed()) { @@ -189,12 +172,6 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } return { - currentResumeCommand: async (): Promise => { - if (config.resumeCommand === undefined) return undefined - const sessions = await listWorkspaceSessions() - if (!sessions.some(header => header.id === agent.session.id)) return undefined - return config.resumeCommand.replaceAll('{session}', agent.session.id) - }, showResume(): void { if (agent.status !== 'idle') { deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') @@ -208,9 +185,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro void resumeOverlay?.close() void sessionQuery.listSessions().then(async (records) => { if (deps.isDisposed() || scan !== resumeScan) return - const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + // Every workspace in the store is summarized; the picker owns the + // current-workspace/all-workspaces scope split over the whole set. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers))) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (deps.isDisposed() || scan !== resumeScan) return @@ -218,7 +196,7 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro create: host => new ResumePicker( candidates, resolved.maxResumeOptions, - runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + workspaceLabel(agent.session.header.cwd), () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, diff --git a/packages/ui/tui/src/chat/timing.ts b/packages/ui/tui/src/chat/timing.ts index 13477adfa0..0aff3bc736 100644 --- a/packages/ui/tui/src/chat/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -290,7 +290,7 @@ export function fadeGlyph( return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m` } if (!visible) return ' ' - return colorEnabled ? palette.muted(glyph) : glyph + return colorEnabled ? palette.dim(glyph) : glyph } /** diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 370be088b1..5e9237574a 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -205,7 +205,7 @@ export class StatusCardComponent implements Component { if (groupIndex > 0) body.push('') for (const [label, value] of group) { const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') - const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const prefix = ` ${this.palette.dim(plainLabel.padEnd(labelWidth))} ` const continuation = ' '.repeat(1 + labelWidth + 2) const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) const wrapped = wrapTextWithAnsi(value, valueWidth) @@ -281,9 +281,10 @@ export function renderDialog( return lines } -/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */ +/** Keyboard model selector rendered as a bordered overlay, with a filter box and per-model reasoning-effort cycling. */ export class ModelDialog implements Component { - private readonly list: SelectList + private list: SelectList + private readonly filter = new Input() private readonly items: Map private readonly choices: Map private readonly efforts: Map @@ -292,10 +293,10 @@ export class ModelDialog implements Component { constructor( choices: readonly ModelChoice[], current: AgentLlmTarget | undefined, - maxVisible: number, + private readonly maxVisible: number, private readonly palette: Palette, - done: (selection: ModelDialogSelection) => void, - cancel: () => void, + private readonly done: (selection: ModelDialogSelection) => void, + private readonly cancel: () => void, ) { this.items = new Map() this.choices = new Map() @@ -317,18 +318,38 @@ export class ModelDialog implements Component { description: this.describeChoice(choice, isCurrent), }) } - this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) - const currentIndex = current === undefined - ? 0 - : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) - this.list.setSelectedIndex(currentIndex) - this.list.onSelect = (item) => { - const selected = choices.find(choice => targetLabel(choice) === item.value) - /* v8 ignore next -- SelectList only returns values built from `choices`. */ - if (selected === undefined) return - done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) - } - this.list.onCancel = cancel + this.list = this.buildList(this.currentValue) + } + + /** Build a SelectList over the currently filtered items, selecting `selectValue` when present. */ + private buildList(selectValue: string | undefined): SelectList { + const items = this.filteredItems() + const list = new SelectList(items, this.maxVisible, dialogSelectTheme(this.palette)) + const index = selectValue === undefined ? 0 : items.findIndex(item => item.value === selectValue) + list.setSelectedIndex(Math.max(0, index)) + list.onSelect = (item) => { this.confirm(item) } + list.onCancel = this.cancel + return list + } + + /** Items matching the filter box, as a case-insensitive substring over the label, model name, and description. */ + private filteredItems(): SelectItem[] { + const query = this.filter.getValue().trim().toLocaleLowerCase() + if (query === '') return [...this.items.values()] + return [...this.items.values()].filter((item) => { + const choice = this.choices.get(item.value) + /* v8 ignore next -- items and choices share the same keys. */ + if (choice === undefined) return false + return [item.value, choice.modelName, choice.description ?? ''] + .some(field => field.toLocaleLowerCase().includes(query)) + }) + } + + private confirm(item: SelectItem): void { + const selected = this.choices.get(item.value) + /* v8 ignore next -- SelectList only returns values built from `choices`. */ + if (selected === undefined) return + this.done({ choice: selected, reasoningEffort: this.efforts.get(item.value) }) } private describeChoice(choice: ModelChoice, isCurrent: boolean): string { @@ -362,24 +383,50 @@ export class ModelDialog implements Component { } invalidate(): void { + this.filter.invalidate() this.list.invalidate() } handleInput(data: string): void { if (matchesKey(data, Key.shift(Key.tab))) { this.cycleReasoningEffort() - } else { + } else if (matchesKey(data, Key.escape)) { + if (this.filter.getValue() === '') this.cancel() + else { + this.filter.setValue('') + this.list = this.buildList(undefined) + } + } else if ( + matchesKey(data, Key.up) + || matchesKey(data, Key.down) + || matchesKey(data, Key.enter) + ) { this.list.handleInput(data) + } else { + const previous = this.filter.getValue() + this.filter.focused = true + this.filter.handleInput(data) + if (this.filter.getValue() !== previous) { + const selected = this.list.getSelectedItem() + this.list = this.buildList(selected?.value) + } } this.invalidate() } render(width: number): string[] { const innerWidth = Math.max(1, width - 4) + this.filter.focused = true + const results = this.filteredItems() + const filterContent = truncateToWidth(this.filter.render(innerWidth).join(''), innerWidth, '') return renderDialog('Select model', [ - ...this.list.render(innerWidth), + filterContent, '', - this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), + ...results.length === 0 + ? [this.palette.dim(' No models match the filter')] + : this.list.render(innerWidth), + '', + this.palette.dim('type to filter • ↑/↓ move • Shift+Tab reasoning • Enter select • Esc'), ], width, this.palette) } } @@ -396,6 +443,10 @@ export interface ResumeCandidate { title: string lastActivityAt: number lastTurn: string + /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ + currentWorkspace: boolean + /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ + workspaceLabel: string route?: ResumeRoute goalPhase?: GoalPhase disabledReason?: string @@ -429,12 +480,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { /** * Build one resume selector row from a record and its log snapshot, deriving the - * title, route, goal phase, and any reason the session cannot be resumed here. + * title, route, goal phase, workspace scope, and any reason the session cannot + * be resumed here. A workspace other than the current one is a scope, not a + * disabled reason: resuming it hands the process off into that directory. * @param record - The session record. * @param snapshot - The session's log snapshot. * @param currentId - The current session id. - * @param cwd - The current workspace directory. + * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. * @param availableProviders - Providers registered in this runtime. + * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. * @returns The summarized resume candidate. */ export function summarizeResumeCandidate( @@ -443,6 +497,7 @@ export function summarizeResumeCandidate( currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, + formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' const route = resumeRoute(snapshot) @@ -450,7 +505,7 @@ export function summarizeResumeCandidate( let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' - else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' else if (route !== undefined && !availableProviders.has(route.provider)) { disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` } @@ -459,6 +514,8 @@ export function summarizeResumeCandidate( title, lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), + currentWorkspace: record.header.cwd === cwd, + workspaceLabel: formatWorkspace(record.header.cwd), ...route === undefined ? {} : { route }, /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, @@ -466,12 +523,23 @@ export function summarizeResumeCandidate( } } -/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +/** Which workspaces the resume picker currently lists. */ +export type ResumeScope = 'workspace' | 'all' + +/** + * Full-viewport keyboard selector over detached, preflighted resume summaries. + * + * Two scopes over one candidate set: `workspace` (the default) lists only the + * current session's workspace, `all` lists every workspace and labels each row + * with its own. Tab toggles between them; the search query and selection reset + * on a scope change so the highlighted row always belongs to the visible list. + */ export class ResumePicker implements Component, Focusable { private readonly search = new Input() private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' + private scope: ResumeScope = 'workspace' focused = false constructor( @@ -488,15 +556,29 @@ export class ResumePicker implements Component, Focusable { this.search.invalidate() } + /** Candidates in the active scope, before the search query narrows them. */ + private scoped(): ResumeCandidate[] { + return this.scope === 'all' + ? [...this.candidates] + : this.candidates.filter(candidate => candidate.currentWorkspace) + } + private filtered(): ResumeCandidate[] { const query = this.search.getValue().trim().toLocaleLowerCase() - if (query === '') return [...this.candidates] - return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) - || candidate.record.header.id.toLocaleLowerCase().includes(query)) + const scoped = this.scoped() + if (query === '') return scoped + // The workspace label only distinguishes rows once it is on screen, so it + // joins the searchable text exactly in the scope that shows it. + return scoped.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query) + || (this.scope === 'all' && candidate.workspaceLabel.toLocaleLowerCase().includes(query))) } private visibleCandidateCount(): number { - const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + // The all-workspaces scope adds a per-row workspace line, so a row costs + // one more terminal row there than in the single-workspace scope. + const rowHeight = this.scope === 'all' ? 5 : 4 + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) return Math.min(this.maxVisible, candidateBudget) } @@ -553,6 +635,11 @@ export class ResumePicker implements Component, Focusable { Math.max(0, filtered.length - 1), this.selectedIndex + this.visibleCandidateCount(), ) + } else if (matchesKey(data, Key.tab)) { + this.scope = this.scope === 'workspace' ? 'all' : 'workspace' + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' @@ -570,6 +657,21 @@ export class ResumePicker implements Component, Focusable { this.invalidate() } + /** + * The scope line under the search box: the active scope with the current + * workspace it means, and the inactive scope with the count Tab would reveal. + */ + private renderScopeLine(): string { + const inWorkspace = this.candidates.filter(candidate => candidate.currentWorkspace).length + const active = this.scope === 'workspace' + ? `this workspace ${displayText(this.workspaceLabel)}` + : `all workspaces (${this.candidates.length})` + const other = this.scope === 'workspace' + ? `all workspaces (${this.candidates.length})` + : `this workspace (${inWorkspace})` + return `${this.palette.accent(active)}${this.palette.dim(` ⇥ ${other}`)}` + } + render(width: number): string[] { this.search.focused = this.focused const height = Math.max(1, this.viewportRows()) @@ -594,7 +696,7 @@ export class ResumePicker implements Component, Focusable { `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, '', - `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + `${indent}${this.renderScopeLine()}`, '', ) @@ -620,8 +722,13 @@ export class ResumePicker implements Component, Focusable { const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + // Only the all-workspaces scope mixes directories, so the per-row + // workspace is redundant in the scope that already names one. + if (this.scope === 'all') { + push(this.palette.dim(` workspace ${displayText(candidate.workspaceLabel)}`)) + } if (candidate.disabledReason !== undefined) { push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } @@ -632,7 +739,7 @@ export class ResumePicker implements Component, Focusable { push(this.palette.error(displayText(this.error))) } - const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Tab scope • Enter resume • Esc clear/cancel')}` while (lines.length < height - 2) lines.push('') lines.push(footer, '') return lines.slice(0, height) @@ -720,7 +827,7 @@ export class QuestionDialog implements Component, Focusable { const innerWidth = Math.max(1, width - 4) const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}` const lines = [ - this.palette.muted(header), + this.palette.dim(header), ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth), ] const push = (line: string): void => { lines.push(line) } @@ -764,7 +871,7 @@ export class QuestionDialog implements Component, Focusable { : left const description = option.description === undefined ? '' - : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}` + : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.dim(displayText(option.description))}` push(`${leftStyled}${description}`) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) diff --git a/packages/ui/tui/src/components/theme.ts b/packages/ui/tui/src/components/theme.ts index 1ea75b437f..269a7c28e8 100644 --- a/packages/ui/tui/src/components/theme.ts +++ b/packages/ui/tui/src/components/theme.ts @@ -11,67 +11,149 @@ import type { TerminalColorScheme, } from '@earendil-works/pi-tui' -/** Theme-agnostic role colors and SGR attribute wrappers. */ +/** + * Text carrying exactly one palette color. Branded so the compiler rejects + * wrapping it in a second color: SGR has no color stack, so an inner span's + * close reverts to the default foreground rather than the outer color, which + * silently drops the outer color for the remainder of the line. + */ +export type Colored = string & { readonly __coloredBy: unique symbol } + +/** + * Text a color may still be applied to: a bare string, or one already carrying + * SGR attributes. Attributes (bold, italic, underline, strike, reverse) occupy + * independent SGR groups from the foreground color, so they compose in either + * order without either side clobbering the other. + */ +export type Colorable = string & { readonly __coloredBy?: undefined } + +/** Applies one color role; rejects input that already carries a color. */ +export type ColorRole = (text: Colorable) => Colored + +/** Applies one SGR attribute; accepts colored or uncolored text and preserves its color. */ +export type AttributeRole = (text: T) => T + +/** + * Theme-agnostic role colors and SGR attribute wrappers. + * + * One role per visual meaning: `dim` is the single recessed tone, `accent` the + * single emphasis color, and `success`/`error` double as a diff's added/removed + * pair. Roles that resolved to the same escape were merged rather than kept as + * aliases, so a reader cannot pick a name that silently renders as another. + * + * Colors and attributes are separately typed: `bold(accent(x))` and + * `accent(bold(x))` both compile, while `accent(error(x))` does not. + */ export interface Palette { - accent: (text: string) => string - accent2: (text: string) => string - text: (text: string) => string - muted: (text: string) => string - dim: (text: string) => string - success: (text: string) => string - warning: (text: string) => string - error: (text: string) => string - code: (text: string) => string - added: (text: string) => string - removed: (text: string) => string - bold: (text: string) => string - italic: (text: string) => string - underline: (text: string) => string - strike: (text: string) => string + accent: ColorRole + /** The terminal's own default foreground; still a color, so it does not stack. */ + text: ColorRole + /** The one recessed tone, below `text`: tool-card bodies, chrome, reasoning, footers. */ + dim: ColorRole + success: ColorRole + warning: ColorRole + error: ColorRole + code: ColorRole + bold: AttributeRole + italic: AttributeRole + underline: AttributeRole + strike: AttributeRole /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ - selected: (text: string) => string + selected: AttributeRole } -function ansi(open: string, close: string, enabled: boolean): (text: string) => string { - return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +/** Names of the palette's color roles, in the order `/palette` prints them. */ +export const COLOR_ROLES = ['text', 'dim', 'accent', 'code', 'success', 'warning', 'error'] as const + +/** Names of the palette's attribute roles, in the order `/palette` prints them. */ +export const ATTRIBUTE_ROLES = ['bold', 'italic', 'underline', 'strike', 'selected'] as const + +/** One role's SGR parameters and the reason it carries them. */ +export interface RoleSpec { + /** SGR parameters that open the span, without the `ESC [` prefix or `m` suffix. */ + readonly open: string + /** SGR parameters that close it; MUST reset every group `open` sets. */ + readonly close: string + /** What the role means, shown by `/palette`. */ + readonly purpose: string } /** - * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR - * attributes, which every terminal remaps to its active color scheme. Body - * `text` stays the terminal's default foreground so it reads on light and dark - * backgrounds alike; grouping uses foreground-only bold, underlined role - * headers and reverse video rather than fixed background fills or per-line - * prefixes, so a transcript drag-select copies message text without stray - * glyphs. + * Every SGR code the TUI is allowed to emit, keyed by role. This table is the + * single source: {@link createPalette} derives the wrappers from it and + * `/palette` prints it, so a role cannot exist in one and not the other, and no + * component hand-writes an escape. + * + * Only the standard 16-color set and SGR attributes appear here. Terminals remap + * those to the user's active theme, so the TUI stays legible on any background; + * a fixed 24-bit color would not. The brand gradient is the one deliberate + * exception ({@link gradientText}). + * + * @param scheme - Active terminal color scheme; only `code` differs between them. + * @returns The SGR spec for every color and attribute role. + */ +export function paletteSpec(scheme: TerminalColorScheme): { + readonly colors: Readonly> + readonly attributes: Readonly> +} { + return { + colors: { + // The terminal's own foreground, emitted as no escape at all: ordinary body + // text must inherit whatever the user's theme uses. + text: { open: '', close: '', purpose: 'Body text, the terminal default foreground' }, + // SGR 2 over an explicit default foreground, closing both groups it sets. + // The attribute fades relative to whatever the terminal's own foreground is, + // which is the only way to land *below* `text` on both schemes: ANSI 90 + // (bright black) is a fixed hue that many light themes render heavier than + // their default foreground, which made every "dim" surface the most + // prominent text on screen. + dim: { open: '2;39', close: '22;39', purpose: 'The one recessed tone: tool bodies, chrome, footers' }, + accent: { open: '95', close: '39', purpose: 'The one emphasis color: role headers, prompt, borders' }, + // ANSI 36 (cyan) is difficult to read on a light background — use ANSI 34 + // (blue) which is legible on both light and dark schemes. + code: scheme === 'light' + ? { open: '34', close: '39', purpose: 'Inline code and code blocks in prose' } + : { open: '36', close: '39', purpose: 'Inline code and code blocks in prose' }, + success: { open: '32', close: '39', purpose: 'Succeeded calls, and a diff\'s added lines' }, + warning: { open: '33', close: '39', purpose: 'Pending calls and warnings' }, + error: { open: '31', close: '39', purpose: 'Failures, signals, and a diff\'s removed lines' }, + }, + attributes: { + bold: { open: '1', close: '22', purpose: 'Emphasis; composes with any color' }, + italic: { open: '3', close: '23', purpose: 'Reasoning text' }, + underline: { open: '4', close: '24', purpose: 'Role-header banding' }, + strike: { open: '9', close: '29', purpose: 'Struck-through Markdown' }, + selected: { open: '7', close: '27', purpose: 'Reverse video for the active selection' }, + }, + } +} + +/** + * Wrap text in an SGR pair, or pass it through when color is disabled. + * An empty `open` emits nothing, so the `text` role costs no escape. + */ +function ansi(spec: RoleSpec, enabled: boolean): (text: string) => string { + if (!enabled || spec.open === '') return text => text + return text => `\x1b[${spec.open}m${text}\x1b[${spec.close}m` +} + +/** + * Theme-agnostic palette derived from {@link paletteSpec}. Body `text` stays the + * terminal's default foreground so it reads on light and dark backgrounds alike; + * grouping uses foreground-only bold, underlined role headers and reverse video + * rather than fixed background fills or per-line prefixes, so a transcript + * drag-select copies message text without stray glyphs. * * @param enabled - Whether ANSI is emitted at all. - * @param scheme - Active terminal color scheme; adjusts dim and code roles. + * @param scheme - Active terminal color scheme; adjusts the code role. * @returns The role palette for the given scheme. */ export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette { - return { - accent: ansi('94', '39', enabled), - accent2: ansi('95', '39', enabled), - text: text => text, - muted: ansi('90', '39', enabled), - // SGR 2 (dim) lightens text on a light background — substitute ANSI 90 - // (bright black / gray) which renders as a readable muted tone on any scheme. - dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled), - success: ansi('32', '39', enabled), - warning: ansi('33', '39', enabled), - error: ansi('31', '39', enabled), - // ANSI 36 (cyan) is difficult to read on a light background — use - // ANSI 34 (blue) which is legible on both light and dark schemes. - code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled), - added: ansi('32', '39', enabled), - removed: ansi('31', '39', enabled), - bold: ansi('1', '22', enabled), - italic: ansi('3', '23', enabled), - underline: ansi('4', '24', enabled), - strike: ansi('9', '29', enabled), - selected: ansi('7', '27', enabled), - } + const spec = paletteSpec(scheme) + const roles = {} as Record + for (const name of COLOR_ROLES) roles[name] = ansi(spec.colors[name], enabled) + for (const name of ATTRIBUTE_ROLES) roles[name] = ansi(spec.attributes[name], enabled) + return roles as unknown as Palette } /** @@ -145,8 +227,8 @@ export function markdownTheme(palette: Palette): MarkdownTheme { // pi-tui presents both fence rows through this callback. Keep the opening // language label, but hide Markdown syntax and the otherwise-empty close. codeBlockBorder: text => palette.dim(text.slice(3)), - quote: text => palette.muted(text), - quoteBorder: text => palette.accent2(text), + quote: text => palette.dim(text), + quoteBorder: text => palette.accent(text), hr: text => palette.dim(text), listBullet: text => palette.accent(text), bold: text => palette.bold(text), @@ -165,7 +247,7 @@ export function selectTheme(palette: Palette): SelectListTheme { return { selectedPrefix: palette.accent, selectedText: palette.accent, - description: palette.muted, + description: palette.dim, scrollInfo: palette.dim, noMatch: palette.warning, } @@ -182,3 +264,49 @@ export function dialogSelectTheme(palette: Palette): SelectListTheme { selectedText: text => palette.selected(palette.accent(text)), } } + +/** Sample text every `/palette` row renders, long enough to judge a tone against its neighbours. */ +const PALETTE_SAMPLE = 'The quick brown fox 0123' + +/** + * Render every palette role as a labelled sample row, each painted by the role + * it names, so a reader compares the actual tones their terminal produces rather + * than reading SGR numbers. Colors print first and attributes second because the + * two groups compose in that order; every row shows its SGR pair so a mismatch + * between the table and the screen is visible. + * + * @param palette - Active role palette, used to paint each sample. + * @param scheme - Active color scheme, reported in the heading and selecting the spec. + * @param colorEnabled - Whether ANSI is emitted; reported so an unstyled listing is not confusing. + * @returns The rendered rows, without a trailing blank. + */ +export function renderPalette( + palette: Palette, + scheme: TerminalColorScheme, + colorEnabled: boolean, +): string[] { + const spec = paletteSpec(scheme) + const width = Math.max(...[...COLOR_ROLES, ...ATTRIBUTE_ROLES].map(name => name.length)) + // Two rows per role: the painted sample beside its name and SGR pair, then the + // purpose indented under it. Splitting the purpose onto its own row keeps every + // sample on one visual line at the narrow widths a side-by-side pane gives. + const head = (name: string, role: RoleSpec, sample: string): string => { + const pair = role.open === '' ? 'no escape' : `ESC[${role.open}m ESC[${role.close}m` + return ` ${sample} ${palette.dim(`${name.padEnd(width)} ${pair}`)}` + } + const purpose = (role: RoleSpec): string => ` ${palette.dim(` ${role.purpose}`)}` + const rows = [ + palette.bold(palette.accent('Palette')), + palette.dim(`${scheme} scheme · color ${colorEnabled ? 'on' : 'off'}`), + '', + palette.dim('Colors — exactly one per span; they never nest inside each other.'), + ] + for (const name of COLOR_ROLES) { + rows.push(head(name, spec.colors[name], palette[name](PALETTE_SAMPLE)), purpose(spec.colors[name])) + } + rows.push('', palette.dim('Attributes — compose with any color, in either order.')) + for (const name of ATTRIBUTE_ROLES) { + rows.push(head(name, spec.attributes[name], palette[name](PALETTE_SAMPLE)), purpose(spec.attributes[name])) + } + return rows +} diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 1e79d94ce8..58d3d6a178 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -25,7 +25,7 @@ import type { ToolResultView, } from '@deepseek-ai/dsh-tools' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { renderUnknownXml } from './xml-tool-output.ts' +import { preview, renderUnknownXml } from './xml-tool-output.ts' import { displayInlineText, displayText } from './text.ts' import { gradientText, type Palette } from './theme.ts' import { contentText, type ParsedArguments } from './content.ts' @@ -58,9 +58,9 @@ function diffLines(diff: FileDiff, palette: Palette): string[] { // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) + for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) return lines } @@ -109,7 +109,7 @@ export class HeaderComponent implements Component { const subtitle = this.subtitle() const lines = [ title, - ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))], + ...subtitle === undefined ? [] : [this.palette.dim(displayText(subtitle))], this.palette.dim(detail), ] .flatMap(line => wrapTextWithAnsi(line, usable)) @@ -147,12 +147,12 @@ function assistantMessageChildren( const text = displayText(textBlocks(content, 'text').trim()) const children: Component[] = [ new Spacer(1), - new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0), + new Text(messageHeader('Assistant', palette.accent, palette), 0, 0), ] if (reasoning && showReasoning) { children.push( - new Text(palette.italic(palette.muted('Reasoning')), 0, 0), - new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }), + new Text(palette.italic(palette.dim('Reasoning')), 0, 0), + new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.dim(value), italic: true }), ) } if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) })) @@ -301,10 +301,27 @@ export class StreamingAssistantComponent extends Container { } } +/** + * A tool card's body split at the Markdown boundary. `prelude` rows are already + * styled and render verbatim (a terminal `$` command, its cwd, a diff's hunks); + * `lines` is the tool's own text. A generic card renders both as one Markdown + * document under the dim body tone. + */ +interface CardBody { + readonly prelude: readonly string[] + readonly lines: readonly string[] +} + +/** + * Ctrl+O card-visibility cycle: `hidden` drops tool cards from the transcript, + * `collapsed` previews the first body lines, `expanded` shows everything. + */ +export type ToolCardVisibility = 'hidden' | 'collapsed' | 'expanded' + /** A tool call and its result, rendered as a collapsible status card. */ export class ToolCardComponent implements Component { private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined - private expanded = false + private visibility: ToolCardVisibility = 'collapsed' private callView: ToolCallView private resultView: ToolResultView | undefined @@ -353,16 +370,19 @@ export class ToolCardComponent implements Component { } /** - * Expand or collapse the card's body preview. - * @param expanded - Whether the full body is shown. + * Set the card's visibility state. + * @param visibility - Hidden, collapsed preview, or full body. */ - setExpanded(expanded: boolean): void { - this.expanded = expanded + setVisibility(visibility: ToolCardVisibility): void { + this.visibility = visibility } invalidate(): void {} render(width: number): string[] { + // Hidden renders nothing — not even the leading gap — so the transcript + // keeps only the conversation, the way Codex hides tool calls. + if (this.visibility === 'hidden') return [] const isError = this.result?.isError ?? false // A ring marker: hollow while the call is pending, filled once it settles; // the header color (warning/success/error) tells pending from ok from error. @@ -374,25 +394,23 @@ export class ToolCardComponent implements Component { ? renderUnknownXml( displayText(contentText(genericContent)), this.maxOutputLines, - this.expanded, + this.visibility === 'expanded', displayText, - text => this.palette.muted(text), + text => this.palette.dim(text), + text => this.palette.dim(text), /* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */ count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`), ) : undefined - const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0 - ? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width) - : rawBody) - const headLines = Math.ceil(this.maxOutputLines / 2) - const tailLines = this.maxOutputLines - headLines - const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines + // A generic card renders title and result as one Markdown document, so the + // document's own block spacing is preserved, then dims every row — the whole + // card body reads as one dim block under the status-colored header. + const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0 + ? this.dimBody(rawBody, width) + : [...rawBody.prelude, ...rawBody.lines]) + const visibleBody = unknownXml !== undefined || this.visibility === 'expanded' ? body - : [ - ...body.slice(0, headLines), - this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`), - ...body.slice(body.length - tailLines), - ] + : preview(body, this.maxOutputLines, count => this.palette.dim(`… +${count} lines (Ctrl+O to expand)`)) // The header is a fixed `Tool / ` frame in the status color (warning // pending / success ok / error), flat — no bold or underline, so one color // reads consistently across the whole row. Every tool-specific detail (a @@ -409,7 +427,9 @@ export class ToolCardComponent implements Component { const desc = this.headerDescription() const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}` const header = truncateToWidth(headerText, Math.max(1, width - 2), '') - const lines = [statusColor(header)] + // The blank first row is the card's own paragraph gap (no external Spacer), + // so the hidden state removes the gap together with the card. + const lines: string[] = ['', statusColor(header)] if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width)) return lines } @@ -438,10 +458,11 @@ export class ToolCardComponent implements Component { return this.resultView?.title ?? this.callView.title } - private renderBody(): string[] { + private renderBody(): CardBody { const view = this.resultView ?? this.callView if (view.card === 'terminal') { const pending = this.terminalPending() + const prelude: string[] = [] const lines: string[] = [] // The command shows as a $-line here whenever it is not the header: either a // description headlines the row (the command still belongs somewhere) or the row @@ -452,18 +473,18 @@ export class ToolCardComponent implements Component { // rows and collide with the output below. const headlined = pending?.description !== undefined && pending.description !== '' const commandInBody = pending !== undefined && (headlined || this.result === undefined) - if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`)) - if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd))) + if (commandInBody) prelude.push(this.palette.dim(`$ ${displayInlineText(pending.title)}`)) + if (pending?.cwd) prelude.push(this.palette.dim(displayInlineText(pending.cwd))) if (this.resultView?.card === 'terminal') { - if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) + if (this.resultView.output) lines.push(...this.dimOutput(this.resultView.output)) if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) if (this.resultView.signal !== undefined) { lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) } } else if (this.result !== undefined) { - lines.push(...displayText(contentText(this.result.content)).split('\n')) + lines.push(...this.dimOutput(contentText(this.result.content))) } - return lines.filter(Boolean) + return { prelude: prelude.filter(Boolean), lines: lines.filter(Boolean) } } if (view.card === 'diff') { // The header no longer names the file, so each diff keeps its own path @@ -477,22 +498,138 @@ export class ToolCardComponent implements Component { }) const files = view.diffs.length const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) - return [...hunks, footer] + // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim + // rather than under the dim result-output color. + return { prelude: [...hunks, footer], lines: [] } } const content = view.content ?? this.result?.content + const prelude: string[] = [] const lines: string[] = [] // The presenter title headlines the body now that the header is a fixed // `Tool / ` frame (a terminal card keeps its command $-line instead). // Skip it when it only repeats the tool name (the fallback presenter for a // tool with no presentCall, or an unknown tool), which the header already shows. const bodyTitle = this.bodyTitle() - if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle)) + if (bodyTitle !== displayText(this.name)) prelude.push(displayInlineText(bodyTitle)) if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) const rawInput = this.result === undefined && this.callView.card === 'generic' ? this.callView.rawInput : undefined if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) - return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) + // Blank-line trimming spans the whole body, so the title counts as a row: + // interior blanks (a result's own paragraph break) survive while the body's + // leading and trailing ones are dropped. + const total = prelude.length + lines.length + return { + prelude, + lines: lines.filter((line, index) => { + const row = prelude.length + index + return line.length > 0 || (row > 0 && row < total - 1) + }), + } + } + + /** + * A tool's own output text as dim rows — the card's result-output color, which + * separates what the tool produced from the card's own framing. A blank row + * stays the empty string so the terminal branch's blank-row filter still reads + * it as blank instead of as an ANSI-wrapped value. + */ + private dimOutput(text: string): string[] { + return displayText(text).split('\n').map(line => line === '' ? line : this.palette.dim(line)) + } + + /** + * Render a generic card's prelude and result as one Markdown document under the + * dim body tone. Rendering both together preserves the document's own block + * spacing (Markdown's blank row before a heading); dimming every row keeps the + * card body one uniform tone, so only the status-colored header carries color. + */ + private dimBody(body: CardBody, width: number): string[] { + const rows = new Markdown([...body.prelude, ...body.lines].join('\n'), 0, 0, this.mdTheme, { + color: value => this.palette.text(value), + }).render(width) + // A whitespace-only row carries no output to dim; leaving it unwrapped keeps + // Markdown's padding out of the styled ranges. + return rows.map(row => row.trim() === '' ? row : this.palette.dim(row)) + } +} + +/** + * Matches a lone reminder-frame tag on its own line, capturing the element name. + * Producers emit the frame as whole lines (`workspace-context`, `dsh-tool-skill`), + * so anchoring the whole line keeps a tag mentioned inside prose from matching. + */ +const REMINDER_FRAME_LINE = /^<(\/?)([a-zA-Z][\w:.-]*)>$/u + +/** + * Drop a producer's outer reminder frame, keeping the instruction body verbatim. + * The card header already names the source, so the frame lines carry nothing. + * Only a matched open/close pair on the first and last lines is removed, so a + * body that merely starts with a tag-like line is left intact. + * @param text - Complete model-facing context text. + * @returns The body without its outer frame lines, trimmed of the blank lines they leave. + */ +function stripReminderFrame(text: string): string { + // A frame needs an open line and a distinct close line, so anything shorter than + // two lines is already frameless. + const [first = '', ...rest] = text.split('\n') + const last = rest.at(-1) + if (last === undefined) return text + const open = REMINDER_FRAME_LINE.exec(first.trim()) + const close = REMINDER_FRAME_LINE.exec(last.trim()) + if (open?.[1] !== '' || close?.[1] !== '/' || open[2] !== close[2]) return text + return rest.slice(0, -1).join('\n').replace(/^\n+|\n+$/gu, '') +} + +/** + * Injected context (plugin/goal source, e.g. `workspace-context`), rendered as a + * collapsible dim card that shares the tool-card `Ctrl+O` toggle. The header is + * `Context ·
) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 0b3ba5e849..7df9de7f36 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -63,8 +63,11 @@ const settled = (over?: Partial): ToolResultNode => ({ describe('terminalCardModel', () => { it('derives a running card from the call view alone', () => { expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({ - command: 'ls -la', cwd: '/projects/app', output: undefined, - exitCode: undefined, signal: undefined, running: true, + description: 'List files', + card: { + command: 'ls -la', cwd: '/projects/app', output: undefined, + exitCode: undefined, signal: undefined, running: true, + }, }) }) @@ -73,12 +76,15 @@ describe('terminalCardModel', () => { callView: callTerminal({ cwd: '/projects/app' }), resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }), }))).toEqual({ - command: 'ls -la', cwd: '/projects/app', output: 'boom\n', - exitCode: 2, signal: undefined, running: false, + description: 'List files', + card: { + command: 'ls -la', cwd: '/projects/app', output: 'boom\n', + exitCode: 2, signal: undefined, running: false, + }, }) expect(terminalCardModel(settled({ resultView: { card: 'terminal', output: '', signal: 'SIGTERM' }, - }))?.signal).toBe('SIGTERM') + }))?.card.signal).toBe('SIGTERM') }) it('takes the result view\'s replacement title over the pending one', () => { @@ -87,30 +93,75 @@ describe('terminalCardModel', () => { expect(terminalCardModel(settled({ callView: callTerminal({ title: 'pnpm run check' }), resultView: resultTerminal({ title: 'pnpm run check --filter web' }), - }))?.command).toBe('pnpm run check --filter web') + }))?.card.command).toBe('pnpm run check --filter web') // Without one, the call's title is what the card keeps. - expect(terminalCardModel(settled())?.command).toBe('ls -la') + expect(terminalCardModel(settled())?.card.command).toBe('ls -la') }) it('resolves the cwd against the session workspace the way the bridge must', () => { // Omitted workdir — the common bash call — IS the session workspace. - expect(terminalCardModel(settled(), '/w/app')?.cwd).toBe('/w/app') + expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app') // A relative workdir joins under it. expect(terminalCardModel(settled({ callView: callTerminal({ cwd: 'packages/ui' }), - }), '/w/app')?.cwd).toBe('/w/app/packages/ui') + }), '/w/app')?.card.cwd).toBe('/w/app/packages/ui') // An absolute one is used as-is. expect(terminalCardModel(settled({ callView: callTerminal({ cwd: '/srv/other' }), - }), '/w/app')?.cwd).toBe('/srv/other') + }), '/w/app')?.card.cwd).toBe('/srv/other') // With no session cwd there is nothing to resolve against: a relative path // stays as authored and an omitted one stays absent (a bare `$` prompt). expect(terminalCardModel(settled({ callView: callTerminal({ cwd: 'packages/ui' }), - }))?.cwd).toBe('packages/ui') - expect(terminalCardModel(settled())?.cwd).toBeUndefined() + }))?.card.cwd).toBe('packages/ui') + expect(terminalCardModel(settled())?.card.cwd).toBeUndefined() // The running arm resolves identically. - expect(terminalCardModel(running(), '/w/app')?.cwd).toBe('/w/app') + expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app') + }) + + it('normalizes a relative workdir so the label names the directory actually used', () => { + // The bash executor resolves the workdir before running, so `..` against + // /w/app runs in /w — the card must say `w`, not `..`. + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '..' }), + }), '/w/app')?.card.cwd).toBe('/w') + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '.' }), + }), '/w/app')?.card.cwd).toBe('/w/app') + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '../sibling' }), + }), '/w/app')?.card.cwd).toBe('/w/sibling') + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: './nested/../other' }), + }), '/w/app')?.card.cwd).toBe('/w/app/other') + // A `..` that would climb past the root is dropped, as a filesystem does. + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '../../..' }), + }), '/w')?.card.cwd).toBe('/') + // An absolute path carrying segments normalizes too. + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '/srv/./app/../other' }), + }), '/w/app')?.card.cwd).toBe('/srv/other') + // A Windows path keeps its separators. + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }), + }), '/w')?.card.cwd).toBe('C:\\ws') + // Without a session cwd a relative `..` has nothing to resolve against, so + // it survives as authored rather than being silently dropped. + expect(terminalCardModel(settled({ + callView: callTerminal({ cwd: '../elsewhere' }), + }))?.card.cwd).toBe('../elsewhere') + }) + + it('carries the call view\'s description, which the contract renders above the card', () => { + expect(terminalCardModel(settled())?.description).toBe('List files') + expect(terminalCardModel(running())?.description).toBe('List files') + // A presenter that supplies none, and a window-truncated call side, both + // leave it absent so the row keeps its args-derived summary. + expect(terminalCardModel(settled({ + callView: { card: 'terminal', title: 'ls' }, + }))?.description).toBeUndefined() + expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined() }) it('a window-truncated call side falls back to the result title, then to an empty command', () => { @@ -118,8 +169,8 @@ describe('terminalCardModel', () => { const truncated = { call: null, callView: null } expect(terminalCardModel(settled({ ...truncated, resultView: resultTerminal({ title: 'ls -la' }), - }))).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) - expect(terminalCardModel(settled(truncated))).toMatchObject({ command: '', cwd: undefined }) + }))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false }) + expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined }) }) it('returns null for every non-terminal call: no views, generic views, unknown cards', () => { @@ -244,6 +295,23 @@ describe('BashRow terminal card', () => { expect(runStateOf(settledView.container)).toBe('done') }) + it('shows the terminal presenter\'s description instead of the args summary', () => { + // `terminal_send`-style presenters author a description the args do not + // repeat; the contract puts it above the card, which is this row's summary. + const view = render() + expect(view.getByText('Terminal 3')).toBeTruthy() + expect(view.queryByText('List files')).toBeNull() + }) + + it('keeps the args-derived summary when the presenter authored no description', () => { + const view = render() + expect(view.getByText('List files')).toBeTruthy() + }) + it('a non-terminal bash call (background start) renders the summary row alone', () => { const view = render( { expect(pre?.textContent).toBe('permission denied') }) - it('a run_code sub-dispatch resolves to its own terminal card', () => { + // The panel resolves a sub-dispatch through the same material as a native + // call, so a sub-call that DID carry terminal views would render the card. + // The shipped wire cannot produce that yet: `session.ts` folds + // `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and + // the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This + // pins the resolution path with views injected directly, and the arm below + // pins what the shipped path actually shows today. + it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => { const view = mount(snapshot({ codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]), }), target) expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy() }) + it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => { + const view = mount(snapshot({ + codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]), + }), target) + // No terminal card: the generic path renders the result text in the Output + // section's
 (the Input section has its own, hence the scoping).
+    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    const output = view.getByText('Output').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('a.ts  b.ts')
+  })
+
   it('a running run_code sub-dispatch resolves through the running material', () => {
     const view = mount(snapshot({
       // The leading non-matching sub-call exercises the scan's skip.

From 79dbe4c6fb478818ed9a5dffe66c163dc16c7375 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 11:45:01 +0800
Subject: [PATCH 111/153] style: wrap the derivePhase JSDoc line

---
 packages/client/runtime/src/client/sessions/session.ts | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts
index 371c74d0a3..0f5d39ac8e 100644
--- a/packages/client/runtime/src/client/sessions/session.ts
+++ b/packages/client/runtime/src/client/sessions/session.ts
@@ -851,7 +851,9 @@ export class Session implements SessionFace {
  * object: `hasContent` only grows within a window and `promptAttempted` is
  * sticky, so blank → engaging → active never steps back; a failed first
  * prompt stays engaging (retry semantics — see ComposerPhase).
- * @param hasContent - any conversation material exists (non-command nodes, partial, running turn, pending waits; command lifecycle rows alone keep the session blank).
+ * @param hasContent - any conversation material exists (non-command nodes,
+ *   partial, running turn, pending waits; command lifecycle rows alone keep
+ *   the session blank).
  * @param promptAttempted - a prompt was initiated on this session object.
  * @returns the derived phase.
  */

From 83c2115de885e0fc4e8a2af9d0f01e15ca3d1bef Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:01:36 +0800
Subject: [PATCH 112/153] refactor(client): command decorations replace the
 hostBacked contribution mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A popup on a host command is not a second command — it is what that
command's BARE invocation does on this client. CommandContribution loses
hostBacked (contributions are pure client commands again; a host-name
collision fails loud, unchanged for /model), and the contract gains
CommandDecoration + command.decorate(): key = the HOST command name, no
catalog row, no claim participation. Dispatch consults decorations only on
the bare paths (menu pick / bare enter) after the host row resolves; space
and argued enter never see them — the two edges hostBacked had to guard
explicitly hold by construction in the decoration model. A decorated name
with no host row in the session's directory never fires (a decoration
cannot manufacture a command).

ui-permission switches register→decorate with zero behavior change
(options still read the permissions projection; a pick still submits
'/permission '). Specs rewrite to the decoration semantics: no
catalog row, bare-enter popup vs argued-enter host claim, space host
claim, no-host-row miss, unavailable fall-through, duplicate fail-loud.
---
 packages/client/ui-command/README.i18n.yaml   |  6 +-
 packages/client/ui-command/README.md          |  2 +-
 packages/client/ui-command/README.zh.md       |  2 +-
 .../client/ui-command/src/client/contract.ts  | 35 +++++++---
 .../client/ui-command/src/client/index.ts     |  2 +-
 .../client/ui-command/src/client/service.ts   | 69 +++++++++++++------
 .../client/ui-command/tests/service.spec.ts   | 50 +++++++++++---
 .../client/ui-permission/README.i18n.yaml     |  4 +-
 packages/client/ui-permission/README.md       |  2 +-
 packages/client/ui-permission/README.zh.md    |  2 +-
 .../client/ui-permission/src/client/index.ts  | 26 ++++---
 .../tests/browser-plugin.spec.ts              | 31 ++++-----
 12 files changed, 150 insertions(+), 81 deletions(-)

diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml
index 6d15efd511..00aa8a1e43 100644
--- a/packages/client/ui-command/README.i18n.yaml
+++ b/packages/client/ui-command/README.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
-README.md: 17bc4edd7d002d6bba4470c9418a9179b2cb131b
-README.zh.md: 1291556409b993aa893e102386f75c45bb195adf
+#   pnpm run verify-translation-pairing --write packages/client/ui-command/README.md
+README.md: 64d06f1d9baae98ef31c7e2a62242eda6a8174da
+README.zh.md: 0cec3b8c8f7baf2fb1c408bccfe8937aa78e4bf9
diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md
index 17bc4edd7d..64d06f1d9b 100644
--- a/packages/client/ui-command/README.md
+++ b/packages/client/ui-command/README.md
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Client command surface (`ctx.command`): the session-keyed command-directory cache, the `/` command source with matchSpace/matchEnter adjudication hooks, three-kind dispatch (execute / popupSelect / leadingInput), and the popupSelect registration face for business packages. Contract: the [web command surfaces Agent Note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md).
 
-`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` is everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
+`src/client/contract.ts` is the frozen business face: `CommandServiceContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes; `CommandUiSpec{options, onSelect}` keeps popup data self-served — the shell component is this package's and business never sees it. A contribution is a client-owned command (a host-name collision fails loud); a decoration hangs a bare-invocation popup on an EXISTING host command — the host keeps its catalog row, argument claim (space / argued enter), and lifecycle logging, and a decorated name with no host row in the session's directory simply never fires. Command kinds derive per dispatch, never per registration: a host descriptor with `input` is leadingInput, a registered `CommandUiSpec` is popupSelect, everything else is execute.
 
 `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session: every session is agent-backed, so `command.list({sessionId})` is the only address shape and the source's scope-birth `warm` hook prewarms the session's entry. Entries are soft-invalidated by the `commands/changed` typed event (old snapshot serves while the repull flies), hard-invalidated by `connection/reset`, epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt.
 
diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md
index 1291556409..0cec3b8c8f 100644
--- a/packages/client/ui-command/README.zh.md
+++ b/packages/client/ui-command/README.zh.md
@@ -4,7 +4,7 @@
 
 客户端命令业务面(`ctx.command`):以会话为 key 的命令目录缓存、带 matchSpace/matchEnter 裁决钩子的 `/` 命令 source、三型派发(execute/popupSelect/leadingInput),以及面向业务包的 popupSelect 注册面。契约:[Web 命令业务面 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md)。
 
-`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
+`src/client/contract.ts` 是冻结的业务表层:`CommandServiceContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容;`CommandUiSpec{options, onSelect}` 让 popup 数据自给自足——壳组件归本包所有,业务永远见不到它。contribution 是 client 自有命令(与 host 同名碰撞即 fail-loud);decoration(装饰)则把裸调用 popup 挂在**已存在的** host 命令上——host 保留目录行、带参 claim(space / 带参 enter)与生命周期记账,被装饰的名字若在会话目录中无 host 行则装饰永不触发。命令三型按每次派发派生,绝不在注册时定型:带 `input` 的 host descriptor 是 leadingInput,注册了 `CommandUiSpec` 的是 popupSelect,其余全部是 execute。
 
 `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key:每个会话恒为 agent-backed,因此 `command.list({sessionId})` 是唯一的寻址形状,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。缓存项由 `commands/changed` 类型化事件软失效(重拉在途期间旧快照继续服务),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。
 
diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts
index e85adfb1f2..61ab4de2e2 100644
--- a/packages/client/ui-command/src/client/contract.ts
+++ b/packages/client/ui-command/src/client/contract.ts
@@ -30,29 +30,37 @@ export type CommandUiSpec = {
  * One client-owned command contribution: a slash-menu entry whose behavior
  * lives entirely on the client (no host descriptor). Merged with the host
  * catalog by name — a collision with a host command fails loud at candidate
- * synthesis, never shadows — UNLESS the contribution declares `hostBacked`:
- * then the same-named host command owns execution and the contribution only
- * supplies the bare-invocation picker (menu row stays the host's; a bare
- * pick/enter opens the popup; a line with arguments falls through to the
- * host command's own path).
+ * synthesis, never shadows.
  */
 export interface CommandContribution {
   /** Command name without the leading slash (unique across contributions). */
   readonly name: string
   /** Menu row description. */
   readonly description: string
-  /**
-   * Cooperate with the same-named host command instead of colliding: the
-   * popup is the bare-invocation UI, the host command is the executor (its
-   * catalog row, argument claim, and lifecycle logging stand unchanged).
-   */
-  readonly hostBacked?: true
   /** Capability filter, called with a fresh projection per candidate pass. */
   available(session: ClientSessionContext): boolean
   /** The command's UI behavior (this phase: popupSelect only). */
   readonly ui: CommandUiSpec
 }
 
+/**
+ * A UI decoration hung on one HOST command: what its BARE invocation does on
+ * this client. Not a second command — the host command keeps its catalog
+ * row, its argument claim (space / argued enter), and its lifecycle logging;
+ * the decoration replaces only the bare menu-pick/enter with a popup whose
+ * onSelect typically submits a completed line back through command.execute.
+ * A decoration never manufactures a row: a name with no host catalog entry
+ * in the session's directory simply never reaches the decoration.
+ */
+export interface CommandDecoration {
+  /** The HOST command name this decorates (without the leading slash). */
+  readonly name: string
+  /** Capability filter, called with a fresh projection per bare invocation. */
+  available(session: ClientSessionContext): boolean
+  /** The bare-invocation UI (this phase: popupSelect only). */
+  readonly ui: CommandUiSpec
+}
+
 /** The `ctx.command` service face visible to business packages. */
 export interface CommandServiceContract {
   /**
@@ -60,6 +68,11 @@ export interface CommandServiceContract {
    * names throw at registration.
    */
   register(contribution: CommandContribution): () => void
+  /**
+   * Hang a bare-invocation decoration on one host command; effect disposer.
+   * Duplicate names throw at registration.
+   */
+  decorate(decoration: CommandDecoration): () => void
   /** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
   popupFor(actx: ClientContext): unknown
 }
diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts
index 4765dc7d86..f40078212e 100644
--- a/packages/client/ui-command/src/client/index.ts
+++ b/packages/client/ui-command/src/client/index.ts
@@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
 export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
 export type { PopupSelectInjected } from './PopupSelectView.tsx'
 export type {
-  CommandContribution, CommandServiceContract, CommandUiSpec, SelectOption,
+  CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
 } from './contract.ts'
 
 declare module 'cordis' {
diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts
index 00118ec14d..9da7b5dbe6 100644
--- a/packages/client/ui-command/src/client/service.ts
+++ b/packages/client/ui-command/src/client/service.ts
@@ -14,7 +14,7 @@ import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
   SubmitOutcome,
 } from '@deepseek-ai/dsh-client-ui-slash/client'
-import type { CommandContribution, CommandServiceContract } from './contract.ts'
+import type { CommandContribution, CommandDecoration, CommandServiceContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
 import { CommandDirectory } from './directory.ts'
 import { PopupSelectController } from './popup.ts'
@@ -23,6 +23,7 @@ import type { TokenSegment } from './popup.ts'
 /** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
 interface LiveState {
   readonly contributions: Map
+  readonly decorations: Map
   readonly popups: Map>
 }
 
@@ -31,7 +32,7 @@ export class CommandService extends Service implements CommandServiceContract {
   static inject = ['slash', 'sessions', 'connection']
 
   private readonly directory: CommandDirectory
-  private readonly live: LiveState = { contributions: new Map(), popups: new Map() }
+  private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
 
   /**
    * @param ctx - owning root context (plugin fiber; the service registers
@@ -79,6 +80,24 @@ export class CommandService extends Service implements CommandServiceContract {
     return () => { void dispose() }
   }
 
+  /**
+   * Hang a bare-invocation decoration on one host command; effect disposer
+   * (rides the caller's fiber). Duplicate names throw.
+   * @param decoration - host command name + availability + popup spec.
+   * @returns the disposer removing the registration.
+   */
+  decorate(decoration: CommandDecoration): () => void {
+    const dispose = this.ctx.effect(() => {
+      const { decorations } = this.live
+      if (decorations.has(decoration.name)) {
+        throw new Error(`ui-command: duplicate decoration for /${decoration.name}`)
+      }
+      decorations.set(decoration.name, decoration)
+      return () => { decorations.delete(decoration.name) }
+    }, 'command.decorate()')
+    return () => { void dispose() }
+  }
+
   /**
    * Resolve the per-session popup controller (lazy; dies with the session
    * scope). The controller's consume callback dispatches the scoped
@@ -139,9 +158,6 @@ export class CommandService extends Service implements CommandServiceContract {
     for (const contribution of this.live.contributions.values()) {
       if (!contribution.available(session)) continue
       if (seen.has(contribution.name)) {
-        // hostBacked cooperates: the host's catalog row stands, the
-        // contribution only supplies the bare-invocation popup.
-        if (contribution.hostBacked === true) continue
         throw new Error(`ui-command: contribution /${contribution.name} collides with a host command`)
       }
       rows.push({ name: contribution.name, description: contribution.description })
@@ -151,16 +167,24 @@ export class CommandService extends Service implements CommandServiceContract {
       .filter(c => req.position === 'leading' || c.hint === undefined)
   }
 
-  /** Decision table, menu column: contribution → popup; host input → claim; host bare → detached execute. */
+  /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */
   private dispatch(pick: SlashPick): PickOutcome {
     const name = pick.candidate.name
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(pick.session)) {
-      this.openPopup(contribution, pick.session, { via: 'menu', span: pick.span })
+      this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
     const desc = this.directory.resolve(pick.session.sessionId, name)
     if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss
+    // A decoration replaces the HOST row's bare invocation with its popup;
+    // it decorates only a resolvable host command (checked above), never
+    // manufactures one, and never touches the argument claim below.
+    const decoration = this.live.decorations.get(name)
+    if (decoration !== undefined && decoration.available(pick.session)) {
+      this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
+      return 'handled'
+    }
     if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
     // Menu-pick execute consumes the trigger span before the detached run
     // (scoped event; the input owns the CAS guard).
@@ -173,10 +197,7 @@ export class CommandService extends Service implements CommandServiceContract {
   private matchSpace(session: ClientSessionContext, token: string): PickOutcome {
     if (!token.startsWith('/')) return undefined
     const name = token.slice(1)
-    // Popup kinds never claim on space; a hostBacked popup defers to the
-    // host command's own claim (the popup serves only the bare invocation).
-    const spaceContribution = this.live.contributions.get(name)
-    if (spaceContribution !== undefined && spaceContribution.hostBacked !== true) return undefined
+    if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
     const desc = this.directory.resolve(session.sessionId, name)
     if (desc === undefined || desc.input === undefined) return undefined
     return { claim: this.leadingClaim(desc, session) }
@@ -198,17 +219,22 @@ export class CommandService extends Service implements CommandServiceContract {
     if (name === '') return undefined
     const contribution = this.live.contributions.get(name)
     if (contribution !== undefined && contribution.available(session)) {
-      if (bare) {
-        this.openPopup(contribution, session, { via: 'enter', token })
-        return 'handled'
-      }
-      // hostBacked + arguments: the host command owns the argued path
-      // (claim or detached run below); a pure contribution stays bare-only.
-      if (contribution.hostBacked !== true) return undefined
+      if (!bare) return undefined
+      this.openPopup(name, contribution.ui, session, { via: 'enter', token })
+      return 'handled'
     }
     await this.directory.ensureReady(session.sessionId, signal)
     const desc = this.directory.resolve(session.sessionId, name)
     if (desc === undefined) return undefined
+    // Bare enter on a decorated host command opens its popup; an argued line
+    // never consults the decoration (the claim/detached paths below own it).
+    if (bare) {
+      const decoration = this.live.decorations.get(name)
+      if (decoration !== undefined && decoration.available(session)) {
+        this.openPopup(name, decoration.ui, session, { via: 'enter', token })
+        return 'handled'
+      }
+    }
     if (desc.input !== undefined) return { claim: this.leadingClaim(desc, session) }
     if (!bare) return undefined
     this.consumeVia(session.sessionId, { via: 'enter', token })
@@ -216,15 +242,16 @@ export class CommandService extends Service implements CommandServiceContract {
     return 'handled'
   }
 
-  /** Open the session's popup for one contribution (menu pick / bare enter). */
+  /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */
   private openPopup(
-    contribution: CommandContribution,
+    name: string,
+    ui: CommandContribution['ui'],
     session: ClientSessionContext,
     segment: TokenSegment,
   ): void {
     const actx = this.scopeFor(session.sessionId)
     if (actx === undefined) return
-    this.popupFor(actx).open(contribution.name, contribution.ui, session, segment)
+    this.popupFor(actx).open(name, ui, session, segment)
   }
 
   /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts
index 6af54ec941..1123c2c48c 100644
--- a/packages/client/ui-command/tests/service.spec.ts
+++ b/packages/client/ui-command/tests/service.spec.ts
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
-import type { CommandContribution, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
+import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts'
 import type { CommandDescriptor } from '../src/client/directory.ts'
 import { CommandService } from '../src/client/service.ts'
 
@@ -198,18 +198,26 @@ describe('candidates', () => {
     await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command')
   })
 
-  it('a hostBacked contribution cooperates: the host row stands, no duplicate, no throw', async () => {
+})
+
+describe('decorations (bare-invocation UI on host commands)', () => {
+  const goalDecoration = (over: Partial = {}): CommandDecoration => ({
+    name: 'goal',
+    available: () => true,
+    ui: themeUi(),
+    ...over,
+  })
+
+  it('adds no catalog row: the host row stands alone', async () => {
     const { command, source } = await bench()
-    command.register(themeContribution({ name: 'goal', hostBacked: true }))
+    command.decorate(goalDecoration())
     const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name)
     expect(names).toEqual(['plan', 'goal'])
   })
-})
 
-describe('hostBacked enter/space columns', () => {
-  it('bare enter opens the popup; an argued line falls through to the host claim', async () => {
+  it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => {
     const { command, source, mint, warm } = await bench()
-    command.register(themeContribution({ name: 'goal', hostBacked: true }))
+    command.decorate(goalDecoration())
     const scope = mint('s1')
     await warm(proj('s1'))
     expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal)).toBe('handled')
@@ -219,14 +227,38 @@ describe('hostBacked enter/space columns', () => {
     expect(argued.claim.token).toBe('/goal ')
   })
 
-  it('space defers to the host claim instead of the popup', async () => {
+  it('space never consults the decoration (host claim)', async () => {
     const { command, source, warm } = await bench()
-    command.register(themeContribution({ name: 'goal', hostBacked: true }))
+    command.decorate(goalDecoration())
     await warm(proj('s1'))
     const outcome = source.matchSpace!(proj('s1'), '/goal')
     if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the host claim')
     expect(outcome.claim.token).toBe('/goal ')
   })
+
+  it('a decoration with no host row never fires (bare enter misses; menu pick misses)', async () => {
+    const { command, source, mint, warm } = await bench()
+    command.decorate(goalDecoration({ name: 'phantom' }))
+    const scope = mint('s1')
+    await warm(proj('s1'))
+    expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal)).toBeUndefined()
+    expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined()
+    expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false)
+  })
+
+  it('an unavailable decoration falls through to the host bare path (detached execute)', async () => {
+    const { command, source, warm, executeCalls } = await bench()
+    command.decorate(goalDecoration({ name: 'plan', available: () => false }))
+    await warm(proj('s1'))
+    expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal)).toBe('handled')
+    expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }])
+  })
+
+  it('duplicate decoration names fail loud', async () => {
+    const { command } = await bench()
+    command.decorate(goalDecoration())
+    expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
+  })
 })
 
 describe('dispatch (menu column)', () => {
diff --git a/packages/client/ui-permission/README.i18n.yaml b/packages/client/ui-permission/README.i18n.yaml
index 0b2d5de3c5..f963bb9dda 100644
--- a/packages/client/ui-permission/README.i18n.yaml
+++ b/packages/client/ui-permission/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-permission/README.md
-README.md: 1782f89c0909ea80f8554bb9b271947a39ae7f8f
-README.zh.md: 6c750329df5bfb25f738869eeb82ea26fa1b8634
+README.md: 0cd8e7f878a151ffacd749eb625afcb20d44ad93
+README.zh.md: 6bc299529c9795ef44cbe5429e78d6355c02a6ca
diff --git a/packages/client/ui-permission/README.md b/packages/client/ui-permission/README.md
index 1782f89c09..0cd8e7f878 100644
--- a/packages/client/ui-permission/README.md
+++ b/packages/client/ui-permission/README.md
@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Permission preset selection plugin, browser half: the `/permission` popupSelect contribution (registered through `ctx.command`). The contribution is `hostBacked` — the host's `/permission` command owns the slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; this entry supplies only the bare-invocation picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The contribution is available exactly while the projection key is present; a permission-less composition shows no picker.
+Permission preset selection plugin, browser half: a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, where a pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows no picker (a decoration never manufactures a catalog row).
 
 The `/client` export surface is the plugin body (`apply`/`inject`).
 
diff --git a/packages/client/ui-permission/README.zh.md b/packages/client/ui-permission/README.zh.md
index 6c750329df..6bc299529c 100644
--- a/packages/client/ui-permission/README.zh.md
+++ b/packages/client/ui-permission/README.zh.md
@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-权限预设选择插件(浏览器半侧):`/permission` popupSelect contribution(经 `ctx.command` 注册)。该 contribution 是 `hostBacked`(宿主背书)的——host 的 `/permission` 命令拥有斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;本入口只提供裸调用的选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。contribution 恰在投影 key 存在时可用;无权限组合不显示选择框。
+权限预设选择插件(浏览器半侧):挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.command.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合不显示选择框(装饰绝不无中生有目录行)。
 
 `/client` 导出面为插件本体(`apply`/`inject`)。
 
diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts
index ff290fda0a..42043ee008 100644
--- a/packages/client/ui-permission/src/client/index.ts
+++ b/packages/client/ui-permission/src/client/index.ts
@@ -1,14 +1,14 @@
 /**
- * Permission preset plugin, browser half — the `/permission` popupSelect
- * (the bare-invocation picker the user asked for: one flat list of presets,
- * current value marked active, a pick executes the switch). The contribution
- * is hostBacked: the host's `/permission` command owns the catalog row, the
- * argued path (`/permission ` still switches directly), and the
- * lifecycle logging — this entry only opens the picker on a bare pick/enter.
- * Options and the active mark read the session's `permissions` projection
- * (the same host-computed select the composer chip renders); a pick submits
- * the `/permission ` command line, so both surfaces write through
- * one path and the pushed projection frame is the one confirmation.
+ * Permission preset plugin, browser half — a popupSelect DECORATION hung on
+ * the host `/permission` command: one flat list of presets, current value
+ * marked active, a pick executes the switch. The decoration owns only the
+ * bare invocation; the host command keeps its catalog row, the argued path
+ * (`/permission ` still switches directly), and the lifecycle
+ * logging. Options and the active mark read the session's `permissions`
+ * projection (the same host-computed select the composer chip renders); a
+ * pick submits the `/permission ` command line, so both surfaces
+ * write through one path and the pushed projection frame is the one
+ * confirmation.
  */
 import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
 import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
@@ -45,10 +45,8 @@ export function apply(ctx: ClientContext): void {
   const sessions = ctx.sessions
   const sessionFor = (session: ClientSessionContext): SessionFace | undefined =>
     sessions.binding(session.sessionId)?.session
-  ctx.effect(() => command.register({
+  ctx.effect(() => command.decorate({
     name: 'permission',
-    description: 'Switch the permission preset (sandbox mode + approval policy)',
-    hostBacked: true,
     // The picker exists exactly while the projection does: a permission-less
     // host serves no key and the bare invocation falls through to the host
     // command (which is absent too — the line simply misses).
@@ -68,5 +66,5 @@ export function apply(ctx: ClientContext): void {
         if (!result.value.matched) throw new Error('the host offers no /permission command')
       },
     },
-  }), 'ui-permission: /permission contribution')
+  }), 'ui-permission: /permission decoration')
 }
diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts
index b9767c732d..167cf17362 100644
--- a/packages/client/ui-permission/tests/browser-plugin.spec.ts
+++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts
@@ -1,7 +1,7 @@
 /**
  * ui-permission browser half on a real cordis Context with fake command/
- * sessions faces: the plugin registers the hostBacked /permission popup
- * contribution; options flatten the session's permissions projection with
+ * sessions faces: the plugin hangs the /permission popup decoration on the
+ * host command; options flatten the session's permissions projection with
  * the current value active and `custom` excluded; availability follows the
  * projection key's presence; a pick submits the /permission line through
  * Session.command and surfaces rejection/unmatched as thrown errors; fiber
@@ -10,7 +10,7 @@
 import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
-import type { CommandContribution } from '@deepseek-ai/dsh-client-ui-command/client'
+import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
 import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
 import { apply, inject } from '../src/client/index.ts'
 
@@ -27,11 +27,11 @@ const SELECT: PermissionSelect = {
 
 async function bench() {
   const ctx = new Context()
-  let contribution: CommandContribution | undefined
+  let decoration: CommandDecoration | undefined
   ctx.provide('command', {
-    register(c: CommandContribution) {
-      contribution = c
-      return () => { contribution = undefined }
+    decorate(c: CommandDecoration) {
+      decoration = c
+      return () => { decoration = undefined }
     },
   })
   const values = new Map()
@@ -59,22 +59,21 @@ async function bench() {
   return {
     ctx, fiber, values, commands,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
-    contribution: () => contribution,
+    decoration: () => decoration,
   }
 }
 
 describe('ui-permission browser plugin', () => {
-  it('registers the hostBacked /permission popup contribution', async () => {
+  it('hangs the /permission popup decoration on the host command', async () => {
     const b = await bench()
-    const c = b.contribution()!
+    const c = b.decoration()!
     expect(c.name).toBe('permission')
-    expect(c.hostBacked).toBe(true)
     expect(c.ui.kind).toBe('popupSelect')
   })
 
   it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
     const b = await bench()
-    const c = b.contribution()!
+    const c = b.decoration()!
     const proj = { sessionId: sid('s1') }
     expect(c.available(proj)).toBe(false)
     b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
@@ -93,7 +92,7 @@ describe('ui-permission browser plugin', () => {
 
   it('a pick submits the /permission line; rejection and unmatched throw', async () => {
     const b = await bench()
-    const c = b.contribution()!
+    const c = b.decoration()!
     const proj = { sessionId: sid('s1') }
     b.values.set(sid('s1'), SELECT)
     await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
@@ -107,10 +106,10 @@ describe('ui-permission browser plugin', () => {
       .rejects.toThrow(/not materialized/)
   })
 
-  it('disposal removes the contribution (HMR safety)', async () => {
+  it('disposal removes the decoration (HMR safety)', async () => {
     const b = await bench()
-    expect(b.contribution()).toBeDefined()
+    expect(b.decoration()).toBeDefined()
     await b.fiber.dispose()
-    expect(b.contribution()).toBeUndefined()
+    expect(b.decoration()).toBeUndefined()
   })
 })

From 0f70886e0c3dfc4afe0f1d3f18e0a6cc892e2b60 Mon Sep 17 00:00:00 2001
From: Chinesezjc 
Date: Wed, 29 Jul 2026 12:10:34 +0800
Subject: [PATCH 113/153] fix(web): label only the first prompt row with the
 working directory
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A multi-line command repeated the cwd label on every prompt row, which
states something the view does not know: it carries ONE working directory —
where the call started — and a `cd` in the command moves later lines
elsewhere. `cd ~` then `ls` rendered both rows labelled with the session
workspace while `ls` actually listed the home directory.

The label now appears on the first row only, and later rows keep a bare `$`
so they still read as prompts. Same reasoning as the run-state dot: neither
a per-line directory nor a per-line exit status exists to report.

The built-bundle snapshot records the effect on fixture turn 60's two-line
command (`fixture echo done` becomes `$ echo done`).
---
 .../feature/2026-07-28-web-terminal-card.i18n.yaml     |  4 ++--
 .../feature/2026-07-28-web-terminal-card.md            |  2 +-
 .../feature/2026-07-28-web-terminal-card.zh.md         |  2 +-
 apps/web/tests/terminal-card.snapshot.ts               |  2 +-
 packages/client/ui-primitives/README.i18n.yaml         |  4 ++--
 packages/client/ui-primitives/README.md                |  2 +-
 packages/client/ui-primitives/README.zh.md             |  2 +-
 packages/client/ui-primitives/src/TerminalBlock.tsx    | 10 +++++++++-
 .../client/ui-primitives/tests/terminal-block.spec.tsx |  8 ++++++++
 9 files changed, 26 insertions(+), 10 deletions(-)

diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
index 022c4f84ed..0f5c43ff36 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md
-2026-07-28-web-terminal-card.md: 8e4701b49373af3495c6f424c4ae2777f5bfcb75
-2026-07-28-web-terminal-card.zh.md: 07341ca942864281390f3820db88388fd61cef0e
+2026-07-28-web-terminal-card.md: 4bf5ee4e7899dceacbc56aced3289647ee42cd82
+2026-07-28-web-terminal-card.zh.md: a7baf5a84364994e938f7c12c368c8202832544a
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
index 8e4701b493..4bf5ee4e78 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
@@ -16,7 +16,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/
 
 The component's contract:
 
-- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command.
+- **Prompt lines, one per command line.** Each line of the command gets its own row: label, then that line verbatim. A `command` carrying two shell commands on two lines therefore reads as the two commands it is, instead of collapsing into one ellipsized row. The label is the cwd's last path segment, or `~` when the cwd equals the `home` prop — a browser has no `$HOME`, so the caller supplies the absolute home directory and the collapse simply does not apply without it. A view with no cwd renders a plain `$`. A trailing newline is a terminator, not an empty final command. Only the FIRST row carries the label: the view knows one working directory — where the call started — and a later line may run somewhere else entirely, since a `cd` in the command is enough to move it. Repeating the label down the rows would state a directory per line that nothing here knows, which is the same reason the run-state dot appears once. Later rows keep a bare `$` so they still read as prompts.
 - **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
 - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
 - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
index 07341ca942..a7baf5a843 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -16,7 +16,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
 
 该组件的契约:
 
-- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。
+- **提示符行,每条命令行一行。** 命令的每一行各占一行:标签,其后原样跟随该行。因此一个在两行上承载两条 shell 命令的 `command` 就读作它本身的两条命令,而不是被压成一行并省略号截断。标签取 cwd 的最后一段路径,当 cwd 等于 `home` prop 时取 `~`——浏览器没有 `$HOME`,因此由调用方提供绝对家目录,不提供时该折叠不生效。视图不带 cwd 时渲染一个纯 `$`。末尾换行是终止符,不是一条空的末命令。只有**第一行**携带该标签:视图只知道一个工作目录——调用开始处的那个——而后面的行完全可能在别处运行,命令里一个 `cd` 就足以改变它。把标签在各行重复,等于陈述一个此处无人知晓的逐行目录,这与运行状态点只出现一次是同一个理由。其余行保留一个裸 `$`,因此它们仍读作提示符。
 - **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
diff --git a/apps/web/tests/terminal-card.snapshot.ts b/apps/web/tests/terminal-card.snapshot.ts
index 0154a3d7fd..82d94b251d 100644
--- a/apps/web/tests/terminal-card.snapshot.ts
+++ b/apps/web/tests/terminal-card.snapshot.ts
@@ -264,7 +264,7 @@ it('the fallback row reaches the same card through its expand control', async ()
       ],
       "prompt": [
         "fixture ls -la",
-        "fixture echo done",
+        "$ echo done",
       ],
       "runState": "done",
       "runStateLabel": "已完成",
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 772851ea82..ad22a9f734 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/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-primitives/README.md
-README.md: 5d71aa920707462f953ed4eb5572b0530b8d0ed2
-README.zh.md: 7c59ed3d3bacbac06a0123e6ff93023a1bcbd028
+README.md: 4222aac4fa1d9c89a3d3c702ee8391fba1eef178
+README.zh.md: e7a5b5ad57048bc59872963b0068c6f88fc772f8
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 5d71aa9207..4222aac4fa 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
 
 ## Terminal output
 
-`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage-return redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
+`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage-return redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md).
 
 ## Model Experience
 
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 7c59ed3d3b..e7a5b5ad57 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -10,7 +10,7 @@
 
 ## 终端输出
 
-`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签,其后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义的控制符;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
+`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义的控制符;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
 
 ## 模型体验
 
diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx
index 3ae7ca7113..0932f4a7f0 100644
--- a/packages/client/ui-primitives/src/TerminalBlock.tsx
+++ b/packages/client/ui-primitives/src/TerminalBlock.tsx
@@ -174,7 +174,15 @@ export function TerminalBlock({
                   per-command status, so a dot per row would assert a
                   per-line outcome nothing here knows. */}
               {index === 0 && }
-              {cwd === undefined ? '$' : promptLabel(cwd, home)}
+              {/* The cwd labels the CALL, so only its first row carries it. The
+                  view knows one working directory — where the call started —
+                  and a later line may well run somewhere else (a `cd` in the
+                  command is enough), so repeating the label down the rows would
+                  assert a directory per line that nothing here knows. Later
+                  rows keep a bare `$` to stay aligned as prompts. */}
+              
+                {index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
+              
               {line}
             
           ))}
diff --git a/packages/client/ui-primitives/tests/terminal-block.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.spec.tsx
index 74352f6fba..d1162fcb7f 100644
--- a/packages/client/ui-primitives/tests/terminal-block.spec.tsx
+++ b/packages/client/ui-primitives/tests/terminal-block.spec.tsx
@@ -212,6 +212,14 @@ describe('TerminalBlock run-state dot', () => {
     expect([...row!.children].map(node => node.textContent)).toEqual(['', 'app', 'ls'])
   })
 
+  // The cwd labels the call, not each line: a `cd` in the command moves later
+  // lines elsewhere, so repeating the label would state a directory per line
+  // that the view does not know.
+  it('labels only the first row with the cwd, leaving later rows a bare $', () => {
+    const view = render()
+    expect(promptRows(view.container)).toEqual(['appcd ~', '$ls'])
+  })
+
   it('gives a multi-line command one row per line', () => {
     const view = render()
     expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])

From 79b1ec2eaed5fd771dc8594a2bd135dfebb6334f Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 12:11:00 +0800
Subject: [PATCH 114/153] fix(host): settle pending approvals as cancelled on
 gateway teardown

Disposability parity with the question provider: a gateway disposed while
approvals are pending settles every registry entry as 'cancelled' (the
service's fail-closed vocabulary), so no ctx.approval ask dangles past the
proxy's lifetime and mux subscribers see the withdrawal. Spec mounts the
proxy on its own fiber and drives dispose with a live ask.

Addresses the ds-review-bot suggestion on PR #851.
---
 packages/host/apiproxy/src/api-proxy.ts       |  7 ++++++
 .../apiproxy/tests/api-proxy-approval.spec.ts | 25 +++++++++++++++++++
 2 files changed, 32 insertions(+)

diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 20b630dc77..0315349fff 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -609,6 +609,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
   // the same rpcId (the refresh-recovery baseline) — and withdraws on the
   // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
   if (ctx.get('approval') !== undefined) {
+    // Teardown parity with the question provider above: a gateway disposed
+    // while approvals are pending settles every entry as 'cancelled' (the
+    // service's fail-closed vocabulary), so no ask promise dangles past the
+    // proxy's lifetime and subscribers see the withdrawal.
+    ctx.effect(() => () => {
+      for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
+    }, 'api-proxy: approval registry teardown')
     ctx.on('approval/request', (req, next) => {
       // The audit pair `approval/asked` is already appended by the service
       // before dispatch, but dispatch rides a microtask: parallel tool calls
diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
index a744224814..632fa898de 100644
--- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
@@ -176,6 +176,31 @@ describe('approval pending registry', () => {
     abort.abort()
   })
 
+  it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
+    // Mount the proxy on its own fiber so disposal exercises the teardown
+    // effect while an ask is still pending.
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SystemPrompt, { persona: '' })
+    await ctx.plugin(UserInteractionService)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(ApprovalService)
+    let api!: ApiProxy
+    const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
+      api = createApiProxy(fiberCtx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
+    }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
+    await fiber.await()
+    const abort = new AbortController()
+    const mux = openMux(api, abort)
+    const asked = ctx.approval.request({ agent: agentOf(ctx), toolName: 'bash' })
+    const requested = requestedOf(await mux.waitFor('approval/requested'))
+    await fiber.dispose()
+    await expect(asked).resolves.toBe('cancelled')
+    const resolved = await mux.waitFor('approval/resolved')
+    expect(resolved).toMatchObject({ approvalId: requested.approvalId, outcome: 'cancelled' })
+    abort.abort()
+  })
+
   it('carries callId on the frame and ignores a late abort after the answer settled', async () => {
     const { ctx, api } = await harness()
     const abort = new AbortController()

From 31189cf403e87a35896ce457283c13472fb5a957 Mon Sep 17 00:00:00 2001
From: Turtle 
Date: Wed, 29 Jul 2026 12:52:14 +0800
Subject: [PATCH 115/153] chore: drop the examples dsh-bash dep left by the
 removed mock-bash fixture

Nothing under examples/ references @deepseek-ai/dsh-bash now that the
tmux-context fixtures are gone; the examples workspace ignores every
@deepseek-ai/* dependency in knip, so no gate could catch it. This leaves
examples/package.json untouched by the PR.
---
 examples/package.json | 1 -
 pnpm-lock.yaml        | 3 ---
 2 files changed, 4 deletions(-)

diff --git a/examples/package.json b/examples/package.json
index f35acb0405..51fc48b8fa 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -10,7 +10,6 @@
     "@deepseek-ai/dsh-acp-demo": "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:*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 356a566630..4fcf7122ea 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -412,9 +412,6 @@ importers:
       '@deepseek-ai/dsh-app-boot':
         specifier: workspace:*
         version: link:../packages/ui/app-boot
-      '@deepseek-ai/dsh-bash':
-        specifier: workspace:*
-        version: link:../packages/bash/bash
       '@deepseek-ai/dsh-bash-local':
         specifier: workspace:*
         version: link:../packages/bash/bash-local

From 99b1a7e895bd3eb8327b6a3a05b6b9e38b00d116 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 13:03:26 +0800
Subject: [PATCH 116/153] fix(host): settle pre-aborted asks at registration;
 make audit pairing callId-symmetric
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Two races from the #572 review, still live in the ported registry:

An ask whose signal aborted between the service's own check and the
microtask-deferred waterfall dispatch would register its abort listener
AFTER the signal fired — never invoked, entry pending forever, zombie
frame on every mux replay. The answerer now settles 'cancelled'
synchronously before publishing anything.

The audit back-scan let a callId-less ask claim the newest unclaimed
asked record even when that record carried another call's id. Pairing is
now shape-symmetric: callId-bearing asks take exactly their call's
record, callId-less asks take only callId-less records — neither can
steal under parallel asks.
---
 packages/host/apiproxy/src/api-proxy.ts       | 12 +++++++-
 .../apiproxy/tests/api-proxy-approval.spec.ts | 30 +++++++++++++++++++
 2 files changed, 41 insertions(+), 1 deletion(-)

diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 0315349fff..f178bfefd0 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -617,6 +617,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
     }, 'api-proxy: approval registry teardown')
     ctx.on('approval/request', (req, next) => {
+      // Dispatch rides a microtask behind the service's own signal check: an
+      // abort landing in that window would register the abort listener AFTER
+      // the signal fired — never invoked, entry pending forever, zombie frame
+      // on every mux replay. Settle synchronously instead of publishing.
+      if (req.signal?.aborted === true) return Promise.resolve('cancelled')
       // The audit pair `approval/asked` is already appended by the service
       // before dispatch, but dispatch rides a microtask: parallel tool calls
       // can append several asked events before any answerer runs. THIS
@@ -634,7 +639,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           decided.add(event.data.id)
         } else if (event.type === 'approval/asked') {
           if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
-          if (req.callId !== undefined && event.data.callId !== req.callId) continue
+          // Symmetric pairing: a callId-bearing ask only takes its own call's
+          // record, and a callId-less ask only takes a callId-less record —
+          // so neither shape can steal the other's audit id under parallel
+          // asks. (Today every producer — the tool executor — passes callId;
+          // the callId-less arm guards any future non-tool asker.)
+          if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
           approvalId = event.data.id
           break
         }
diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
index 632fa898de..f9a00cf9ef 100644
--- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
@@ -176,6 +176,36 @@ describe('approval pending registry', () => {
     abort.abort()
   })
 
+  it('an ask whose signal aborted before dispatch settles cancelled without publishing', async () => {
+    // The service checks the signal, then dispatch rides a microtask: an
+    // abort in that window must not register a dead listener and strand the
+    // entry (zombie frame on every replay). Drive the waterfall directly
+    // with a pre-aborted signal to hit the answerer's register-path guard.
+    const { ctx, api } = await harness()
+    const abort = new AbortController()
+    const mux = openMux(api, abort)
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
+    const agent = { session } as unknown as Agent
+    const cancelled = new AbortController()
+    cancelled.abort()
+    const outcome = await ctx.waterfall(
+      'approval/request',
+      { agent, toolName: 'bash', signal: cancelled.signal },
+      () => Promise.resolve('unavailable' as const),
+    )
+    expect(outcome).toBe('cancelled')
+    // Nothing was published: a fresh mux open replays no approval frame.
+    const abort2 = new AbortController()
+    const mux2 = openMux(api, abort2)
+    await new Promise(resolve => setTimeout(resolve, 10))
+    expect(mux2.envelopes.some(e => e.payload.type === 'approval/requested')).toBe(false)
+    abort2.abort()
+    abort.abort()
+    void mux
+  })
+
   it('gateway teardown settles pending approvals as cancelled (question-provider parity)', async () => {
     // Mount the proxy on its own fiber so disposal exercises the teardown
     // effect while an ask is still pending.

From 9e10ba0f15a9cd5693222afd7c270d0a5ed801cd Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 13:03:26 +0800
Subject: [PATCH 117/153] fix(client): drop generation-scoped interaction state
 at generation death
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Clearing waitingApprovals in handleConnected raced the reconnect replay:
mux frames flow from stream open while onConnected waits for the
readiness handshake, so a replayed approval/requested could land first
and be wiped — amber dot and answerable card lost until the next
generation. The sweep moves to generation death (onStateChange
'reconnecting'), before any next-generation frame can exist, and now
also drops buffered answerable frames (approval/question pairs) whose
dead-generation rpcIds could never be answered — a session instantiated
later no longer replays zombie takeover cards. session/queued buffering
already re-baselines per generation; this closes the same window for
the interaction frames.
---
 packages/client/runtime/src/client/index.ts   |  6 +++++
 .../runtime/src/client/sessions/manager.ts    | 26 ++++++++++++++++---
 .../runtime/src/client/sessions/service.ts    |  5 ++++
 packages/client/runtime/tests/manager.spec.ts | 24 +++++++++++++++--
 4 files changed, 55 insertions(+), 6 deletions(-)

diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index f697fd3f1a..3b8e02b5a9 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/packages/client/runtime/src/client/index.ts
@@ -154,6 +154,12 @@ export function apply(ctx: Context): void {
       workspaces.handleConnected()
       ctx.emit('connection/reset')
     },
+    onStateChange: (state) => {
+      // Generation death fires before any next-generation frame can arrive
+      // (reconnect replays flow from stream open, ahead of onConnected):
+      // the only safe moment to drop generation-scoped interaction state.
+      if (state === 'reconnecting') sessions.handleDisconnected()
+    },
   })
   ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
 }
diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts
index 62a0241df5..396c0be6e7 100644
--- a/packages/client/runtime/src/client/sessions/manager.ts
+++ b/packages/client/runtime/src/client/sessions/manager.ts
@@ -443,14 +443,32 @@ export class SessionManager {
     }
   }
 
-  /** After each connection generation: refresh the session baseline and rebuild opened windows. */
-  handleConnected(): void {
-    // Approvals resolved while disconnected send no frame: drop the bits and
-    // let the mux-open replay re-add every still-pending question.
+  /**
+   * The moment a connection generation dies (before any next-generation frame
+   * can arrive — onConnected waits for the readiness handshake while replayed
+   * frames flow from stream open, so clearing there would race the replay):
+   * drop generation-scoped live state. Approvals resolved while disconnected
+   * send no frame, so the stale bits and the buffered answerable frames must
+   * not survive into the next generation — the mux-open replay re-adds every
+   * still-pending question with its live rpcId.
+   */
+  handleDisconnected(): void {
     if (this.waitingApprovals.size > 0) {
       this.waitingApprovals.clear()
       this.notifier.markDirty()
     }
+    for (const [sessionId, buffer] of [...this.pendingBuffers]) {
+      const kept = buffer.filter(item =>
+        item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
+        && item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
+      if (kept.length === buffer.length) continue
+      if (kept.length === 0) this.pendingBuffers.delete(sessionId)
+      else this.pendingBuffers.set(sessionId, kept)
+    }
+  }
+
+  /** After each connection generation: refresh the session baseline and rebuild opened windows. */
+  handleConnected(): void {
     void this.refreshList()
     for (const session of this.sessions.values()) void session.resync()
   }
diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts
index 27965c2902..71067d6330 100644
--- a/packages/client/runtime/src/client/sessions/service.ts
+++ b/packages/client/runtime/src/client/sessions/service.ts
@@ -294,6 +294,11 @@ export class SessionsService implements ISessions {
     this.manager.handleConnected()
   }
 
+  /** Drop generation-scoped live interaction state the moment a connection generation dies. */
+  handleDisconnected(): void {
+    this.manager.handleDisconnected()
+  }
+
   /**
    * Create a session on the host. Resolution guarantee: by the time the
    * promise resolves, the created session is in the list store and
diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts
index 90ab196059..33b46538d9 100644
--- a/packages/client/runtime/tests/manager.spec.ts
+++ b/packages/client/runtime/tests/manager.spec.ts
@@ -406,12 +406,32 @@ describe('waiting-approval list bit', () => {
     expect(manager.getListSnapshot().items).toHaveLength(0)
   })
 
-  it('drops stale bits on reconnect — the reopen replay re-adds still-pending questions', () => {
+  it('drops stale bits at generation death — BEFORE the reopen replay re-adds still-pending questions', () => {
     const manager = new SessionManager(new FakeApiClient())
     manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
     manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
     expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
-    manager.handleConnected() // resolved-while-disconnected questions send no frame
+    // Generation death clears (resolved-while-disconnected questions send no frame)…
+    manager.handleDisconnected()
     expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(false)
+    // …and a replayed frame arriving before onConnected (stream open precedes
+    // the readiness handshake) survives the later handleConnected untouched.
+    manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
+    manager.handleConnected()
+    expect(manager.getListSnapshot().items[0]?.waitingApproval).toBe(true)
+  })
+
+  it('generation death drops buffered answerable frames (a dead generation cannot be answered)', () => {
+    const manager = new SessionManager(new FakeApiClient())
+    manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', sessionId: S1, blank: false } })
+    // Buffered pre-instantiation: an approval pair and a queued row.
+    manager.handleMuxEnvelope({ rpcId: 'ra' as never, payload: { type: 'approval/requested', sessionId: S1, approvalId: 'ap1' as never, toolName: 'rm' } })
+    manager.handleMuxEnvelope({ rpcId: 'q1' as never, payload: { type: 'question/requested', sessionId: S1, questions: [] } })
+    manager.handleDisconnected()
+    // Instantiate after the death sweep: no zombie interaction replays (the
+    // pendingBuffers held only dead-generation rpcIds), so the session mints
+    // no pending waits.
+    const session = manager.get(S1)
+    expect(session.getSnapshot().pending).toEqual([])
   })
 })

From bbe1481a9ecd4b4d85dc4954b343696b534c4d49 Mon Sep 17 00:00:00 2001
From: Chinesezjc 
Date: Wed, 29 Jul 2026 13:10:44 +0800
Subject: [PATCH 118/153] fix(web): keep escapes, UNC roots, and truncated cwd
 honest
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Four review findings. Two are defects the previous two rounds introduced,
which the existing tests did not catch:

A backspace erased raw bytes, so one landing after an SGR reset ate part of
the escape: `\x1b[31mabc\x1b[0m\b\bXY` left `\x1b[` and repainted the rest of
the line with whatever the remainder parsed as. Backspaces now resolve over
VISIBLE characters — a CSI sequence is one indivisible unit a backspace steps
over on its way to the last printed character, so the surviving text keeps
the color its run authored.

The cwd normalizer popped a UNC share root: `\\server\share` with a `..`
became `/server`, losing the separators too. A UNC path's server and share
are its root, and Windows cannot climb above a share, so they are split off
and the remainder collapses against that root.

The other two are gaps the earlier fixes left:

The render-site fallback row still passed the args-derived summary, so any
terminal-declaring tool without its own keyed row (`terminal_send`) lost the
contract's above-card description. It now prefers the description exactly as
BashRow does.

A settled call read `call?.cwd`, which cannot tell "the call omitted a cwd"
from "the paging window dropped the call head". The second case has no cwd
anywhere and the original call may have used an explicit workdir, so it now
draws a bare `$` instead of naming the session workspace.
---
 .../2026-07-28-web-terminal-card.i18n.yaml    |  4 +-
 .../feature/2026-07-28-web-terminal-card.md   |  4 +-
 .../2026-07-28-web-terminal-card.zh.md        |  4 +-
 .../src/client/chat/GenericToolCard.tsx       |  7 ++-
 .../client/contract/terminal-card-model.ts    | 53 +++++++++++++++----
 .../tests/terminal-card.spec.tsx              | 36 +++++++++++++
 packages/client/ui-primitives/src/ansi.ts     | 49 ++++++++++++++---
 .../client/ui-primitives/tests/ansi.spec.ts   | 18 +++++++
 8 files changed, 150 insertions(+), 25 deletions(-)

diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
index 0f5c43ff36..0d805c8de0 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md
-2026-07-28-web-terminal-card.md: 4bf5ee4e7899dceacbc56aced3289647ee42cd82
-2026-07-28-web-terminal-card.zh.md: a7baf5a84364994e938f7c12c368c8202832544a
+2026-07-28-web-terminal-card.md: 87cb40331bca6a1bfcdd4e8ecd9bbe69c6572be0
+2026-07-28-web-terminal-card.zh.md: ab5a35d6f76427cf1e0338d8491bd3485cb41302
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
index 4bf5ee4e78..87cb40331b 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
@@ -12,7 +12,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/
 
 ## Decision
 
-`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary.
+`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary.
 
 The component's contract:
 
@@ -20,7 +20,7 @@ The component's contract:
 - **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes.
 - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding.
 - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends.
-- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Two cursor movements resolve before that strip, because their effect on the visible text has to land before the characters expressing them are dropped: a carriage return reduces its line to the final redraw, and a backspace overwrites the character before it, so `abc` followed by two backspaces and `XY` reads `aXY` as a terminal draws it. Both are per-line, so neither reaches across a newline.
+- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Two cursor movements resolve before that strip, because their effect on the visible text has to land before the characters expressing them are dropped: a carriage return reduces its line to the final redraw, and a backspace overwrites the character before it, so `abc` followed by two backspaces and `XY` reads `aXY` as a terminal draws it. Both are per-line, so neither reaches across a newline. A backspace steps over CSI sequences rather than erasing their bytes: a sequence moves no cursor, and eating part of one would corrupt it and repaint everything after with whatever the mangled remainder parses as, so it walks back to the last PRINTED character and drops that instead — the surviving text keeps the color its run authored.
 - **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard.
 
 Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks.
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
index a7baf5a843..ab5a35d6f7 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -12,7 +12,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
 
 ## Decision
 
-`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。
+`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。
 
 该组件的契约:
 
@@ -20,7 +20,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
 - **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。
 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。
 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。
-- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。两种光标移动在该剥除之前先行结算,因为它们对可见文本的作用必须先落地,之后才能丢弃表达它们的那些字符:回车把所在行归约为最后一次重绘,退格覆盖它前面的字符——于是 `abc` 后接两个退格再接 `XY` 读作 `aXY`,与终端的绘制一致。两者都按行结算,因此都不会跨越换行。
+- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。两种光标移动在该剥除之前先行结算,因为它们对可见文本的作用必须先落地,之后才能丢弃表达它们的那些字符:回车把所在行归约为最后一次重绘,退格覆盖它前面的字符——于是 `abc` 后接两个退格再接 `XY` 读作 `aXY`,与终端的绘制一致。两者都按行结算,因此都不会跨越换行。退格会跨过 CSI 序列,而不是擦掉它的字节:序列本身不移动光标,吃掉它的一部分会破坏该序列,并让其后的一切按被损坏的残余重新着色,因此退格回退到最后一个**已打印**字符并删除它——存活下来的文本保留其所在分段所声明的颜色。
 - **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。
 
 几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index 5b93d66e32..ce55d84f57 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -28,6 +28,7 @@ const VARIANT_ICONS: Record = {
 
 export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
+  const terminal = terminalCardModel(block, cwd)
   const singleFile = model.filePath !== undefined
   return (
      0 && kept[kept.length - 1] !== '..') kept.pop()
-      else if (leading === '' && drive === '') kept.push(segment)
+      else if (!rooted) kept.push(segment)
       continue
     }
     kept.push(segment)
   }
-  const body = kept.join(separator)
-  return drive === '' ? `${leading}${body}` : `${drive}${leading === '' ? separator : leading}${body}`
+  return kept.join(separator)
 }
 
 /**
@@ -146,7 +174,12 @@ export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): Te
       // (the presentation contract's replacement-title rule); the call title is
       // what a result without one keeps.
       command: result.title ?? call?.title ?? '',
-      cwd: resolveTerminalCwd(call?.cwd, sessionCwd),
+      // Only a PRESENT call view can mean "omitted the cwd, so use the
+      // workspace". When the window dropped the call head there is no cwd
+      // anywhere — the result view carries none — and the original call may
+      // well have used an explicit workdir, so the prompt draws a bare `$`
+      // rather than naming a directory this card cannot know.
+      cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
       output: result.output,
       exitCode: result.exitCode,
       signal: result.signal,
diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx
index 7df9de7f36..dd096c6e91 100644
--- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx
+++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx
@@ -153,6 +153,32 @@ describe('terminalCardModel', () => {
     }))?.card.cwd).toBe('../elsewhere')
   })
 
+  it('keeps a UNC server and share as an unpoppable root', () => {
+    // Windows cannot climb above a share, so `..` from the share root stays put.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
+    // Below the share it pops normally, keeping the UNC separators.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+    // Several `..` cannot escape the root either.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../../..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+  })
+
+  it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
+    // A truncated call carries no cwd anywhere: the result view has none, and
+    // the original call may have used an explicit workdir. Falling back to the
+    // session workspace here would name a directory the card cannot know.
+    expect(terminalCardModel(settled({
+      call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
+    }), '/w/app')?.card.cwd).toBeUndefined()
+    // A present call view that omits its cwd still means the workspace.
+    expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
+  })
+
   it('carries the call view\'s description, which the contract renders above the card', () => {
     expect(terminalCardModel(settled())?.description).toBe('List files')
     expect(terminalCardModel(running())?.description).toBe('List files')
@@ -231,6 +257,16 @@ describe('chat row terminal body', () => {
     expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
   })
 
+  it('the fallback row shows the presenter description, not the args summary', () => {
+    // Any terminal-declaring tool without its own keyed row lands here, so the
+    // contract's above-card description has to win at this render site as well.
+    const view = render()
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+  })
+
   it('a running terminal call expands to the prompt line with no output yet', () => {
     const view = render()
     fireEvent.click(view.container.querySelector('button')!)
diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts
index bf05d0d2c8..115d3faffc 100644
--- a/packages/client/ui-primitives/src/ansi.ts
+++ b/packages/client/ui-primitives/src/ansi.ts
@@ -114,14 +114,49 @@ function applyCarriageReturns(text: string): string {
  */
 function applyBackspaces(text: string): string {
   if (!text.includes('\u0008')) return text
-  return text.split('\n').map((line) => {
-    const kept: string[] = []
-    for (const char of line) {
-      if (char === '\u0008') kept.pop()
-      else kept.push(char)
+  return text.split('\n').map(applyBackspacesToLine).join('\n')
+}
+
+/**
+ * One line's backspaces, resolved over VISIBLE characters only. A CSI sequence
+ * moves no cursor, so it must survive intact: erasing its bytes would corrupt
+ * the sequence and repaint the rest of the output with whatever the mangled
+ * remainder parses as. The sequences are therefore held as indivisible units
+ * that a backspace steps over on its way to the last printed character, and a
+ * unit already erased stays erased so a run's own color still applies to what
+ * remains of it.
+ * @param line - one output line, still carrying its CSI sequences.
+ * @returns the line with each backspace applied to the character before it.
+ */
+function applyBackspacesToLine(line: string): string {
+  if (!line.includes('\u0008')) return line
+  const units: { text: string; visible: boolean }[] = []
+  // Same shape anser splits on: CSI ... final byte. Matched here so a sequence
+  // is one unit rather than a run of erasable characters.
+  const csi = /\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/g
+  let at = 0
+  for (const match of line.matchAll(csi)) {
+    for (const char of line.slice(at, match.index)) units.push({ text: char, visible: true })
+    units.push({ text: match[0], visible: false })
+    at = match.index + match[0].length
+  }
+  for (const char of line.slice(at)) units.push({ text: char, visible: true })
+
+  const kept: { text: string; visible: boolean }[] = []
+  for (const unit of units) {
+    if (unit.visible && unit.text === '\u0008') {
+      // Walk back past any escapes to the last printed character and drop it,
+      // keeping those escapes so the surviving text stays styled as authored.
+      for (let index = kept.length - 1; index >= 0; index--) {
+        if (kept[index]?.visible !== true) continue
+        kept.splice(index, 1)
+        break
+      }
+      continue
     }
-    return kept.join('')
-  }).join('\n')
+    kept.push(unit)
+  }
+  return kept.map(unit => unit.text).join('')
 }
 
 /**
diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts
index 0fd4f6391e..76af75a565 100644
--- a/packages/client/ui-primitives/tests/ansi.spec.ts
+++ b/packages/client/ui-primitives/tests/ansi.spec.ts
@@ -184,6 +184,24 @@ describe('parseAnsiLines: backspaces', () => {
     ])
   })
 
+  it('steps over an SGR sequence instead of erasing its bytes', () => {
+    // `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
+    // corrupt it and repaint the rest of the line with whatever the remainder
+    // parses as. The visible result is `aXY`, still red, with the reset intact.
+    expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
+      { text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+      { text: 'XY', style: undefined },
+    ]])
+  })
+
+  it('erases across a style boundary without dropping the styles between', () => {
+    // The backspace reaches back past the reset to the last printed character.
+    expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
+      { text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
+      { text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+
   it('applies the overwrite after a carriage-return redraw, not before', () => {
     // The redraw wins first; the backspace then erases inside what survived.
     expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined })

From 6ff2c536612b11c493ca5d0761024d522a242314 Mon Sep 17 00:00:00 2001
From: kingwl 
Date: Wed, 29 Jul 2026 13:14:36 +0800
Subject: [PATCH 119/153] fix(deps): declare dsh-llm as a peer of plan-mode and
 tool-tasks

Both packages import @deepseek-ai/dsh-llm at runtime (createUserMessage)
but declared it only in devDependencies; any resolver that honors
declared runtime dependencies resolves the import to a stale or missing
artifact. Found by the (since removed) source-launch declared-dependency
check.
---
 packages/plan/plan-mode/package.json   | 1 +
 packages/tasks/tool-tasks/package.json | 1 +
 2 files changed, 2 insertions(+)

diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json
index 70ed87889d..6507ea4a0c 100644
--- a/packages/plan/plan-mode/package.json
+++ b/packages/plan/plan-mode/package.json
@@ -39,6 +39,7 @@
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-commands": "^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-projection": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json
index 2fd0b464a4..c63e49ddc9 100644
--- a/packages/tasks/tool-tasks/package.json
+++ b/packages/tasks/tool-tasks/package.json
@@ -32,6 +32,7 @@
   "peerDependencies": {
     "@deepseek-ai/dsh-agent": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
     "@deepseek-ai/dsh-retention": "^0.0.1",
     "@deepseek-ai/dsh-system-prompt": "^0.0.1",
     "@deepseek-ai/dsh-tasks": "^0.0.1",

From aebf9c863b4a19bf7a7b4861d4f31dbfab73adfc Mon Sep 17 00:00:00 2001
From: kingwl 
Date: Wed, 29 Jul 2026 13:15:24 +0800
Subject: [PATCH 120/153] feat(cli): launch dsh source through the tsx ESM hook

Node 26.0.0 removed --experimental-transform-types, so the native
source-launch chain cannot start anywhere on that line, and strip-only
mode rejects the vendored syntax (parameter properties, decorators,
runtime enums/namespaces). Switch bin/dsh, the root dsh/demo:tui/
demo:web scripts, and the Code Mode TUI overlay to node --import
tsx/esm: one launch vector across the whole engines range, ~0.4s faster
than the full tsx default (the CJS hook stays off; the graph is
ESM-only).

Delete scripts/tspath-loader.ts and apps/cli/src/tsconfig-paths-loader.ts:
tsx owns both transformation and tsconfig paths projection. Add
dsh-source-launch-smoke to the node-compat gates so the 22.19/26 matrix
executes the real launch vector; no CI job did, which is how the Node 26
breakage shipped silently.

Supersedes the native-TypeScript-source-launch Agent Note (new note
records the profiling evidence and rejected alternatives).
---
 ...-native-typescript-source-launch.i18n.yaml |   4 +-
 ...-28-dsh-native-typescript-source-launch.md |   2 +
 ...-dsh-native-typescript-source-launch.zh.md |   2 +
 ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml |   6 +
 .../2026-07-29-dsh-source-launch-tsx-esm.md   |  38 +++
 ...2026-07-29-dsh-source-launch-tsx-esm.zh.md |  38 +++
 AGENTS.md                                     |   2 +-
 apps/cli/README.i18n.yaml                     |   4 +-
 apps/cli/README.md                            |   2 +-
 apps/cli/README.zh.md                         |   2 +-
 apps/cli/src/tsconfig-paths-loader.ts         | 216 ------------------
 apps/cli/tests/source-launch.compat.spec.ts   |  36 +++
 apps/cli/tests/tsconfig-paths-loader.spec.ts  | 180 ---------------
 bin/dsh                                       |  17 +-
 package.json                                  |   6 +-
 scripts/demo-code-mode.mjs                    |   3 +-
 scripts/run-gates.ts                          |   5 +
 scripts/tspath-loader.ts                      |  14 --
 18 files changed, 148 insertions(+), 429 deletions(-)
 create mode 100644 .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml
 create mode 100644 .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
 create mode 100644 .agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
 delete mode 100644 apps/cli/src/tsconfig-paths-loader.ts
 create mode 100644 apps/cli/tests/source-launch.compat.spec.ts
 delete mode 100644 apps/cli/tests/tsconfig-paths-loader.spec.ts
 delete mode 100644 scripts/tspath-loader.ts

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 a96c124e92..f3a3226c11 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: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b
-2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0
+2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b
+2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c
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 019389f3e5..1ba1dd2663 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
@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md)
 
+> The Node-native launch vector is superseded by [dsh source launch through the tsx ESM hook](2026-07-29-dsh-source-launch-tsx-esm.md): Node 26.0.0 removed `--experimental-transform-types`, and the paths loader described here is deleted. The Cordis-config declaration gate (`verify-cordis-config`), the app-boot fail-loud plugin diagnostic, and the vendored `import type` marks remain current.
+
 ## Problem
 
 The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch.
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 2cfff25d22..02f84f3482 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
@@ -4,6 +4,8 @@ Status: implemented
 
 [English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文
 
+> Node 原生启动向量已被 [dsh 通过 tsx ESM hook 源码启动](2026-07-29-dsh-source-launch-tsx-esm.md) 取代:Node 26.0.0 移除了 `--experimental-transform-types`,本文描述的 paths loader 已删除。Cordis 配置声明门禁(`verify-cordis-config`)、app-boot 的 fail-loud 插件诊断以及 vendor 中的 `import type` 标注仍然有效。
+
 ## 问题
 
 `dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml
new file mode 100644
index 0000000000..ab618103c2
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md
+2026-07-29-dsh-source-launch-tsx-esm.md: 93fbb248b45efde37d5fbdb1ec4b812ab3332088
+2026-07-29-dsh-source-launch-tsx-esm.zh.md: 48f410bd846e5808cc95180279348a0ac5ba1c95
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
new file mode 100644
index 0000000000..93fbb248b4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md
@@ -0,0 +1,38 @@
+# Agent Note: dsh source launch through the tsx ESM hook
+
+Status: implemented
+
+English | [中文](2026-07-29-dsh-source-launch-tsx-esm.zh.md)
+
+> Supersedes [native TypeScript source launch](2026-07-28-dsh-native-typescript-source-launch.md): Node removed the capability that decision was built on.
+
+## Problem
+
+The [native source-launch decision](2026-07-28-dsh-native-typescript-source-launch.md) ran `apps/cli/src/bin.ts` under `node --experimental-transform-types` with a resolve-only paths loader, so Node owned TypeScript transformation. Node 26.0.0 removed `--experimental-transform-types` (the process rejects the flag with `bad option`), keeping only strip mode, and strip mode rejects syntax this source graph requires: vendored Cordis parameter properties (`constructor(private ctx: Context)`), the `@Inject` decorators in `vendor/hmr`, and runtime enums/namespaces throughout `vendor/` and `packages/workflow`. The repository's engines range (`^22.19.0 || >=24.0.0`) includes Node 26, so the native launch chain could not start at all there — and no CI job executed the real launch vector, so the incompatibility shipped silently.
+
+Startup latency also mattered: the off-thread `module.register()` hooks worker serialized every resolution across threads (~440ms of `makeSyncRequest` wait during TUI boot), and the full tsx default (`--import tsx`) pays ~0.4s in its CJS hook's resolution amplification.
+
+## Decision
+
+The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain.
+
+`scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; fixed alongside this change.)
+
+The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`.
+
+## Alternatives considered
+
+**Keep the native chain on Node ≤25 and branch by version.** Rejected: two transformation semantics (amaro versus esbuild) diverge on edge syntax, the launcher grows version probing, and the node-compat matrix must cover both paths — heavy maintenance for an experimental flag that already changed under us. amaro also rejects the `@Inject` decorators `vendor/hmr` uses, so the native path could not boot the shipped default TUI config anyway.
+
+**Make the source graph erasable-only so Node 26 strip mode accepts it.** Rejected: parameter properties and value namespaces pervade vendored Cordis/cosmokit/loader/schemastery; rewriting them is unbounded churn re-applied on every vendor sync.
+
+**A repo-owned in-thread loader (`module.registerHooks()` + esbuild or `@swc/core` transform).** Rejected for now: prototypes measured ~0.45s (esbuild path untested end-to-end; SWC breaks on `vendor/hmr`'s decorator + namespace merge in both decorator modes), but it means owning transform correctness and a resolve hook that tsx already provides. Revisit only if the ~0.3s gap becomes a real cost; the profiling evidence lives in the PR discussion.
+
+**Run built `lib/` for Node 26 and keep native for 24.** Rejected: loses the zero-build development loop on the newest Node line and mixes source and artifact planes.
+
+## Consequences
+
+- One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line.
+- TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode.
+- The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures.
+- Startup improves ~0.4s over the full tsx default (`demo:headless` and ACP keep `--import tsx`; their graphs were not audited for CJS-hook dependence and their launch latency is not on the interactive path).
diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
new file mode 100644
index 0000000000..48f410bd84
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md
@@ -0,0 +1,38 @@
+# Agent Note: dsh 通过 tsx ESM hook 源码启动
+
+Status: implemented
+
+[English](2026-07-29-dsh-source-launch-tsx-esm.md) | 中文
+
+> 取代[原生 TypeScript 源码启动](2026-07-28-dsh-native-typescript-source-launch.md):Node 移除了该决策所依赖的能力。
+
+## 问题
+
+[原生源码启动决策](2026-07-28-dsh-native-typescript-source-launch.md)让 `apps/cli/src/bin.ts` 在 `node --experimental-transform-types` 下运行,配合一个只做解析的 paths loader,由 Node 负责 TypeScript 转换。Node 26.0.0 移除了 `--experimental-transform-types`(进程以 `bad option` 拒绝该 flag),只保留 strip 模式,而 strip 模式无法接受这个源码图必需的语法:vendor Cordis 中的参数属性(`constructor(private ctx: Context)`)、`vendor/hmr` 中的 `@Inject` 装饰器,以及遍布 `vendor/` 与 `packages/workflow` 的运行时 enum/namespace。仓库的 engines 范围(`^22.19.0 || >=24.0.0`)包含 Node 26,因此原生启动链在其上完全无法启动——且没有任何 CI 任务执行过真实启动向量,这一不兼容悄然发布。
+
+启动延迟同样是问题:off-thread 的 `module.register()` hooks worker 把每次解析都跨线程序列化(TUI 启动期间约 440ms 的 `makeSyncRequest` 等待),而完整 tsx 默认形态(`--import tsx`)的 CJS hook 解析放大要多付约 0.4s。
+
+## 决策
+
+`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only hook 同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用 hook 与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS hook 保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。
+
+`scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader "仅为已声明运行时依赖映射 workspace import" 的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest 走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;已随本变更修复。)
+
+node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块 hook 或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。
+
+## 备选方案
+
+**在 Node ≤25 保留原生链并按版本分叉。** 拒绝:两套转换语义(amaro 与 esbuild)在边缘语法上会分歧,启动器要加版本探测,node-compat 矩阵要覆盖两条路径——为一个已经变动过的 experimental flag 付出沉重维护。而且 amaro 也不支持 `vendor/hmr` 使用的 `@Inject` 装饰器,原生路径本来就无法启动随附的默认 TUI 配置。
+
+**把源码图改成 erasable-only 以适配 Node 26 strip 模式。** 拒绝:参数属性与值 namespace 遍布 vendor 的 Cordis/cosmokit/loader/schemastery;改写是无界 churn,且每次 vendor sync 都要重做。
+
+**仓库自有的同线程 loader(`module.registerHooks()` + esbuild 或 `@swc/core` 转换)。** 暂拒:原型实测约 0.45s(esbuild 路径未端到端验证;SWC 在 `vendor/hmr` 的装饰器 + namespace 合并上两种装饰器模式都会崩),但意味着自行负责转换正确性和一个 tsx 已经提供的 resolve hook。仅当约 0.3s 的差距成为真实成本时再重启;profiling 证据在 PR 讨论中。
+
+**Node 26 运行构建产物 `lib/`,24 保留原生。** 拒绝:在最新 Node 版本线上失去零构建开发循环,且混淆源码面与产物面。
+
+## 结果
+
+- 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。
+- TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 note "证明 Node 原生转换可用" 的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。
+- 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。
+- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 与 ACP 保持 `--import tsx`:其依赖图未就 CJS hook 依赖性做审计,且其启动延迟不在交互路径上)。
diff --git a/AGENTS.md b/AGENTS.md
index a89bb82bf1..bf8321d618 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 ## Conventions
 
 - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.
-- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md).
+- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only shapes) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)).
 - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns.
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index e9e5df7630..457f0aba98 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: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9
-README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b
+README.md: 3d029e5c97647526357c34df1d87edc904b92e33
+README.zh.md: 70930fa7035effad3ab65926e474fa53883546d7
diff --git a/apps/cli/README.md b/apps/cli/README.md
index 13a80b1d0e..3d029e5c97 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -26,6 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
+Source launches run `apps/cli/src/bin.ts` through tsx's ESM-only hook (`node --import tsx/esm`), which transforms TypeScript and projects the root tsconfig `paths` map into module resolution. Node's native TypeScript modes are not used: Node 26 removed `--experimental-transform-types`, and strip-only mode rejects syntax the source graph relies on (vendored parameter properties, decorators, runtime enums/namespaces). The CJS hook stays off because the source graph is ESM-only and the CJS resolver adds ~0.4s of startup. `bin/dsh` pins `TSX_TSCONFIG_PATH` to the checkout's root tsconfig so resolution is cwd-independent, and the `dsh-source-launch-smoke` node-compat gate runs this exact launch vector on every supported Node line. tsx applies the `paths` map without checking dependency declarations, so declaration completeness rests on the static gates: the TUI configs resolve bare plugins through `examples/package.json`, the Web/headless `cordis.yml` through this package's `dependencies`, and `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies.
 
 `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 2a5d9c15c5..70930fa703 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -26,6 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将
 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
 ```
 
-源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
+源码启动会通过 tsx 的 ESM-only hook(`node --import tsx/esm`)运行 `apps/cli/src/bin.ts`,由它转换 TypeScript 并将根 tsconfig 的 `paths` 映射投射到模块解析中。不使用 Node 原生 TypeScript 模式:Node 26 移除了 `--experimental-transform-types`,而 strip-only 模式无法接受源码图依赖的语法(vendor 中的参数属性、装饰器、运行时 enum/namespace)。CJS hook 保持关闭,因为源码图是纯 ESM,而 CJS 解析器会增加约 0.4s 启动耗时。`bin/dsh` 将 `TSX_TSCONFIG_PATH` 固定到 checkout 的根 tsconfig,使解析与 cwd 无关;node-compat 门禁 `dsh-source-launch-smoke` 会在每条受支持的 Node 版本线上运行这一精确启动向量。tsx 应用 `paths` 映射时不检查依赖声明,声明完整性由静态门禁保障:TUI 配置通过 `examples/package.json` 解析裸插件,Web/无头 `cordis.yml` 通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。
 
 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。
diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts
deleted file mode 100644
index b7337998bc..0000000000
--- a/apps/cli/src/tsconfig-paths-loader.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-/**
- * Node module resolve hook for the `dsh` source launcher. It projects the root
- * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax
- * handling to Node's native transform-types runtime.
- * @module @deepseek-ai/dsh/tsconfig-paths-loader
- */
-
-import { readFile, stat } from 'node:fs/promises'
-import { dirname, extname, join, resolve } from 'node:path'
-import { fileURLToPath, pathToFileURL } from 'node:url'
-import type { ResolveHookContext, ResolveFnOutput } from 'node:module'
-import ts from 'typescript'
-
-interface LoaderData {
-  tsconfigPath: string
-}
-
-interface PackageManifest {
-  name?: string
-  dependencies?: Record
-  optionalDependencies?: Record
-  peerDependencies?: Record
-}
-
-interface PathRule {
-  pattern: string
-  prefix: string
-  suffix: string
-  targets: readonly string[]
-}
-
-interface PathsCompilerOptions {
-  readonly baseUrl?: string
-  readonly paths?: ts.MapLike
-  readonly pathsBasePath?: string
-}
-
-// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded.
-const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const
-
-/**
- * Resolve package imports through one parsed tsconfig paths table.
- *
- * Manifest reads are process-scoped and memoized by path. Only matched source
- * aliases enter the cache, bounding it to directories participating in source
- * resolution.
- */
-export class TsconfigPathsResolver {
-  private readonly rules: readonly PathRule[]
-  private readonly configDirectory: string
-  private readonly manifests = new Map>()
-
-  private constructor(configDirectory: string, paths: ts.MapLike) {
-    this.configDirectory = configDirectory
-    this.rules = Object.entries(paths)
-      .map(([pattern, targets]) => {
-        const wildcard = pattern.indexOf('*')
-        return {
-          pattern,
-          prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard),
-          suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1),
-          targets,
-        }
-      })
-      .sort((left, right) => {
-        const leftExact = left.pattern.includes('*') ? 0 : 1
-        const rightExact = right.pattern.includes('*') ? 0 : 1
-        return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length
-      })
-  }
-
-  /**
-   * Parse a tsconfig including its `extends` chain.
-   * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`.
-   * @returns A resolver backed by that path table.
-   */
-  static create(tsconfigPath: string): TsconfigPathsResolver {
-    let unrecoverable: ts.Diagnostic | undefined
-    const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
-      ...ts.sys,
-      onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic },
-    })
-    if (parsed === undefined) {
-      const detail = unrecoverable === undefined
-        ? 'unknown configuration error'
-        : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n')
-      throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`)
-    }
-    const options = parsed.options as PathsCompilerOptions
-    const paths = options.paths
-    if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`)
-    const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath)
-    return new TsconfigPathsResolver(configDirectory, paths)
-  }
-
-  /**
-   * Resolve one bare package specifier to a source file when the importing
-   * package (or config-directory owner) declares that package at runtime.
-   * @param specifier Module specifier passed to Node.
-   * @param parentURL Importing file or Loader config-directory URL.
-   * @returns Source file URL, or `undefined` when normal Node resolution owns the request.
-   */
-  async resolve(specifier: string, parentURL: string | undefined): Promise {
-    const packageName = packageNameFromSpecifier(specifier)
-    if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined
-    const matched = this.match(specifier)
-    if (matched === undefined) return undefined
-    const configParent = parentURL.endsWith('/')
-    const parentPath = fileURLToPath(parentURL)
-    const startDirectory = configParent ? parentPath : dirname(parentPath)
-    if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined
-
-    for (const target of matched.targets) {
-      const substituted = target.replace('*', matched.wildcard)
-      const candidate = await existingSourcePath(resolve(this.configDirectory, substituted))
-      if (candidate !== undefined) return pathToFileURL(candidate).href
-    }
-    return undefined
-  }
-
-  private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined {
-    for (const rule of this.rules) {
-      if (!rule.pattern.includes('*')) {
-        if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' }
-        continue
-      }
-      if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue
-      const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length)
-      return { targets: rule.targets, wildcard }
-    }
-    return undefined
-  }
-
-  private async isDeclaredRuntimeDependency(
-    startDirectory: string,
-    packageName: string,
-    searchAncestors: boolean,
-  ): Promise {
-    for (let directory = startDirectory; ; directory = dirname(directory)) {
-      const manifest = await this.readManifest(join(directory, 'package.json'))
-      if (manifest !== undefined) {
-        if (declaresRuntimeDependency(manifest, packageName)) return true
-        if (!searchAncestors) return false
-      }
-      const parent = dirname(directory)
-      if (parent === directory) return false
-    }
-  }
-
-  private readManifest(path: string): Promise {
-    let pending = this.manifests.get(path)
-    if (pending !== undefined) return pending
-    pending = readFile(path, 'utf8').then(
-      content => JSON.parse(content) as PackageManifest,
-      (error: unknown) => {
-        if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
-        throw error
-      },
-    )
-    this.manifests.set(path, pending)
-    return pending
-  }
-}
-
-let resolver: TsconfigPathsResolver | undefined
-
-/** Initialize the hook worker from the source-launch preloader. */
-export function initialize(data: LoaderData): void {
-  resolver = TsconfigPathsResolver.create(data.tsconfigPath)
-}
-
-/** Resolve declared workspace packages to source and delegate every other request to Node. */
-export async function resolveHook(
-  specifier: string,
-  context: ResolveHookContext,
-  nextResolve: (specifier: string, context: ResolveHookContext) => Promise,
-): Promise {
-  const url = await resolver?.resolve(specifier, context.parentURL)
-  return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true }
-}
-
-// Node customization hooks discover this exact export name.
-export { resolveHook as resolve }
-
-function packageNameFromSpecifier(specifier: string): string | undefined {
-  if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) {
-    return undefined
-  }
-  const segments = specifier.split('/')
-  return specifier.startsWith('@')
-    ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined
-    : segments[0] || undefined
-}
-
-function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean {
-  return manifest.name === packageName
-    || packageName in (manifest.dependencies ?? {})
-    || packageName in (manifest.optionalDependencies ?? {})
-    || packageName in (manifest.peerDependencies ?? {})
-}
-
-async function existingSourcePath(base: string): Promise {
-  const extension = extname(base)
-  if (extension === '.tsx') return undefined
-  const candidates = extension === ''
-    ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))]
-    : [base]
-  for (const candidate of candidates) {
-    try {
-      if ((await stat(candidate)).isFile()) return candidate
-    } catch (error) {
-      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
-    }
-  }
-  return undefined
-}
diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts
new file mode 100644
index 0000000000..bc6959261a
--- /dev/null
+++ b/apps/cli/tests/source-launch.compat.spec.ts
@@ -0,0 +1,36 @@
+import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
+ * with the exact production launch vector (`node --import tsx/esm`, the same
+ * shape as `bin/dsh` and the root `dsh`/`demo:tui`/`demo:web` scripts) and
+ * assert the piped-stdio TTY refusal. The Node compatibility matrix runs this
+ * WHOLE file, so a Node release changing module hooks or TypeScript handling
+ * breaks this gate instead of every developer's `pnpm dsh`; the built-bin
+ * suite covers the published `lib/` entry, not this source chain.
+ */
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const dshSourceBin = 'apps/cli/src/bin.ts'
+
+describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
+  it('boots the source entry and refuses pipes LOUD (non-zero exit + stderr)', async () => {
+    const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
+      cwd: repoRoot,
+      input: '',
+      timeout: 25_000,
+      killSignal: 'SIGKILL',
+      reject: false,
+    })
+    if (result.timedOut) {
+      throw new Error(`dsh source launch did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
+    }
+    expect(result.exitCode).not.toBe(0)
+    expect(result.stderr).toContain('requires stdin and stdout to be interactive TTYs')
+    expect(result.stderr).toContain('dsh -p')
+    // The refusal happens before any plugin mounts: stdout stays silent.
+    expect(result.stdout).toBe('')
+  }, 30_000)
+})
diff --git a/apps/cli/tests/tsconfig-paths-loader.spec.ts b/apps/cli/tests/tsconfig-paths-loader.spec.ts
deleted file mode 100644
index 834ee8c4d0..0000000000
--- a/apps/cli/tests/tsconfig-paths-loader.spec.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import type { ResolveFnOutput, ResolveHookContext } from 'node:module'
-import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts'
-
-class ResolverFixture {
-  readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-'))
-
-  path(relativePath: string): string {
-    return join(this.root, relativePath)
-  }
-
-  write(relativePath: string, content = 'export {}\n'): string {
-    const path = this.path(relativePath)
-    mkdirSync(dirname(path), { recursive: true })
-    writeFileSync(path, content)
-    return path
-  }
-
-  writeJson(relativePath: string, value: unknown): string {
-    return this.write(relativePath, `${JSON.stringify(value)}\n`)
-  }
-
-  createResolver(paths: Record): TsconfigPathsResolver {
-    const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } })
-    return TsconfigPathsResolver.create(tsconfigPath)
-  }
-
-  parentURL(relativePath = 'consumer/src/nested/index.ts'): string {
-    return pathToFileURL(this.path(relativePath)).href
-  }
-
-  dispose(): void {
-    rmSync(this.root, { recursive: true, force: true })
-  }
-}
-
-const fixtures: ResolverFixture[] = []
-
-function fixture(): ResolverFixture {
-  const value = new ResolverFixture()
-  fixtures.push(value)
-  return value
-}
-
-afterEach(() => {
-  for (const value of fixtures.splice(0)) value.dispose()
-})
-
-describe('TsconfigPathsResolver', () => {
-  it('orders exact, longer-prefix, and longer-suffix path rules', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', {
-      dependencies: {
-        '@scope/feature-name': '*',
-        '@scope/feature-other': '*',
-        '@scope/plain-suffix': '*',
-      },
-    })
-    files.write('targets/exact.ts')
-    files.write('targets/prefix/other.ts')
-    files.write('targets/generic/feature-other.ts')
-    files.write('targets/suffix/plain.ts')
-    files.write('targets/generic/plain-suffix.ts')
-    const resolver = files.createResolver({
-      '@scope/*': ['./targets/generic/*'],
-      '@scope/*-suffix': ['./targets/suffix/*'],
-      '@scope/feature-*': ['./targets/prefix/*'],
-      '@scope/feature-name': ['./targets/exact.ts'],
-    })
-
-    await expect(resolver.resolve('@scope/feature-name', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href)
-    await expect(resolver.resolve('@scope/feature-other', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href)
-    await expect(resolver.resolve('@scope/plain-suffix', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href)
-  })
-
-  it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', {
-      name: 'self-package',
-      dependencies: { dependency: '*' },
-      optionalDependencies: { optional: '*' },
-      peerDependencies: { peer: '*' },
-    })
-    for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) {
-      files.write(`targets/${name}.ts`)
-    }
-    const resolver = files.createResolver(Object.fromEntries(
-      ['self-package', 'dependency', 'optional', 'peer', 'undeclared']
-        .map(name => [name, [`./targets/${name}`]]),
-    ))
-
-    for (const name of ['self-package', 'dependency', 'optional', 'peer']) {
-      await expect(resolver.resolve(name, files.parentURL()))
-        .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href)
-    }
-    await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined()
-  })
-
-  it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => {
-    const files = fixture()
-    const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing']
-    files.writeJson('consumer/package.json', {
-      dependencies: Object.fromEntries(names.map(name => [name, '*'])),
-    })
-    files.write('targets/plain.ts')
-    files.write('targets/module.mts')
-    files.write('targets/common.cts')
-    files.write('targets/directory/index.ts')
-    files.write('targets/component.tsx')
-    const resolver = files.createResolver({
-      'plain-ts': ['./targets/plain'],
-      'module-mts': ['./targets/module'],
-      'common-cts': ['./targets/common'],
-      'directory': ['./targets/directory'],
-      'tsx-implicit': ['./targets/component'],
-      'tsx-explicit': ['./targets/component.tsx'],
-      'missing': ['./targets/missing'],
-    })
-
-    for (const [name, target] of [
-      ['plain-ts', 'targets/plain.ts'],
-      ['module-mts', 'targets/module.mts'],
-      ['common-cts', 'targets/common.cts'],
-      ['directory', 'targets/directory/index.ts'],
-    ] as const) {
-      await expect(resolver.resolve(name, files.parentURL()))
-        .resolves.toBe(pathToFileURL(files.path(target)).href)
-    }
-    await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined()
-    await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined()
-    await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined()
-  })
-
-  it('anchors inherited paths at the config that declared them', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', { dependencies: { custom: '*' } })
-    files.write('targets/custom.ts')
-    files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } })
-    const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' })
-    const resolver = TsconfigPathsResolver.create(customTsconfig)
-
-    await expect(resolver.resolve('custom', files.parentURL()))
-      .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href)
-  })
-
-  it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => {
-    const files = fixture()
-    files.writeJson('consumer/package.json', { dependencies: { matched: '*' } })
-    const target = files.write('targets/matched.ts')
-    const tsconfigPath = files.writeJson('tsconfig.json', {
-      compilerOptions: { paths: { matched: ['./targets/matched'] } },
-    })
-    initialize({ tsconfigPath })
-    const context: ResolveHookContext = {
-      conditions: [],
-      importAttributes: {},
-      parentURL: files.parentURL(),
-    }
-    const nextResolve = vi.fn(async (
-      specifier: string,
-      _context: ResolveHookContext,
-    ): Promise => ({ url: `next:${specifier}` }))
-
-    await expect(resolveHook('matched', context, nextResolve))
-      .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true })
-    expect(nextResolve).not.toHaveBeenCalled()
-
-    for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) {
-      await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` })
-      expect(nextResolve).toHaveBeenLastCalledWith(specifier, context)
-    }
-  })
-})
diff --git a/bin/dsh b/bin/dsh
index 319d915b18..f85f28a5cd 100755
--- a/bin/dsh
+++ b/bin/dsh
@@ -1,7 +1,7 @@
 #!/bin/sh
-# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native
-# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh)
-# always executes the current working tree without a build step.
+# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through the tsx ESM
+# hook, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the
+# current working tree without a build step.
 set -eu
 
 # Resolve symlink chains without readlink -f (not on every macOS).
@@ -15,8 +15,11 @@ while [ -L "$script" ]; do
 done
 root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd)
 
-# The preloader projects this checkout's tsconfig paths into Node resolution;
-# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook).
-exec node --experimental-transform-types \
-  --import "$root/scripts/tspath-loader.ts" \
+# The ESM-only tsx hook transforms TypeScript and projects this checkout's
+# tsconfig paths into Node resolution (the CJS hook stays off: the graph is
+# ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep
+# both the hook and the tsconfig anchored to this checkout when the launcher
+# runs from any cwd, where bare `tsx/esm` would not resolve.
+TSX_TSCONFIG_PATH="$root/tsconfig.json" \
+  exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \
   "$root/apps/cli/src/bin.ts" "$@"
diff --git a/package.json b/package.json
index afa74fdf1e..a51261a4c2 100644
--- a/package.json
+++ b/package.json
@@ -96,13 +96,13 @@
     "constraints": "tsx scripts/check-workspace-constraints.ts",
     "doc-sync": "tsx scripts/run-gates.ts doc-sync",
     "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
-    "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
+    "dsh": "node --import tsx/esm apps/cli/src/bin.ts",
     "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml",
-    "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts",
+    "demo:tui": "node --import tsx/esm apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
     "demo:cordis": "node scripts/demo-cordis.mjs",
     "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml",
-    "demo:web": "npm run build && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web",
+    "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web",
     "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts",
     "dev:web": "tsx scripts/dev-web.ts --poll",
     "postinstall": "node scripts/install-lefthook.mjs"
diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs
index d790fe15d5..994bea32d0 100644
--- a/scripts/demo-code-mode.mjs
+++ b/scripts/demo-code-mode.mjs
@@ -8,9 +8,8 @@ import { spawn } from 'node:child_process'
 // Each UI's node invocation matches its base demo script plus the overlay config.
 const UIS = new Map([
   ['tui', [
-    '--experimental-transform-types',
     '--import',
-    './scripts/tspath-loader.ts',
+    'tsx/esm',
     'apps/cli/src/bin.ts',
     '--config',
     'examples/tui-agent/code-mode.cordis.yml',
diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts
index aa2378b24f..d10a9ccba7 100644
--- a/scripts/run-gates.ts
+++ b/scripts/run-gates.ts
@@ -287,6 +287,11 @@ function nodeCompatSmokeGates(): Gate[] {
       'run',
       'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
     ], { label: 'JSONL Zstandard smoke' }),
+    pnpmExec('dsh-source-launch-smoke', [
+      'vitest',
+      'run',
+      'apps/cli/tests/source-launch.compat.spec.ts',
+    ], { label: 'dsh source-launch smoke' }),
   ]
 }
 
diff --git a/scripts/tspath-loader.ts b/scripts/tspath-loader.ts
deleted file mode 100644
index adeecb6b64..0000000000
--- a/scripts/tspath-loader.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */
-
-import { register } from 'node:module'
-import { resolve } from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined
-  ? fileURLToPath(new URL('../tsconfig.json', import.meta.url))
-  : resolve(process.env.TSX_TSCONFIG_PATH)
-
-register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), {
-  parentURL: import.meta.url,
-  data: { tsconfigPath },
-})

From 173a1a83198141ced8578508aefc651784f7afb4 Mon Sep 17 00:00:00 2001
From: Hypatia May 
Date: Wed, 29 Jul 2026 13:38:55 +0800
Subject: [PATCH 121/153] fix(dev-infra): migrate copied worktree hooks

---
 ...26-07-27-worktree-local-lefthook.i18n.yaml |  4 +-
 .../2026-07-27-worktree-local-lefthook.md     |  4 +-
 .../2026-07-27-worktree-local-lefthook.zh.md  |  4 +-
 docs/development.i18n.yaml                    |  4 +-
 docs/development.md                           |  2 +-
 docs/development.zh.md                        |  2 +-
 scripts/install-lefthook.mjs                  | 24 ++++++++--
 scripts/install-lefthook.spec.ts              | 48 +++++++++++++++++++
 .../request-response.expected.json            |  4 +-
 9 files changed, 81 insertions(+), 15 deletions(-)

diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
index 34dcf42c4f..4eb85b7d70 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.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/process/2026-07-27-worktree-local-lefthook.md
-2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c
-2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc
+2026-07-27-worktree-local-lefthook.md: 75dfd47087356c34005ec4673e174e451a72c660
+2026-07-27-worktree-local-lefthook.zh.md: bc4902769561c3d33d2101de55e28e70d114f39b
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
index d18f6c1bf8..75dfd47087 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
@@ -16,9 +16,9 @@ Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, t
 
 Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
 
-Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
+Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
 
-If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
+If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, copied new-worktree configuration, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
 
 ## Alternatives considered
 
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
index 42a1625a3b..bc49027695 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
@@ -16,9 +16,9 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
 
 升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复,不会被自动破坏。
 
-每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
+每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
 
-若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
+若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、复制的新 worktree 配置、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
 
 ## 考虑过的替代方案
 
diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml
index 74d9dfbea5..311f872b4a 100644
--- a/docs/development.i18n.yaml
+++ b/docs/development.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/development.md
-development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca
-development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc
+development.md: 0a18e29d3da4f694707521e230017e6b22cad740
+development.zh.md: 885b51c701267215cc50d31ecd1694ae2c9af9ca
diff --git a/docs/development.md b/docs/development.md
index 32339fa2af..0a18e29d3d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta
 node scripts/install-lefthook.mjs
 ```
 
-The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.
+The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.
 
 Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.
 
diff --git a/docs/development.zh.md b/docs/development.zh.md
index c74a813466..885b51c701 100644
--- a/docs/development.zh.md
+++ b/docs/development.zh.md
@@ -27,7 +27,7 @@ pnpm install
 node scripts/install-lefthook.mjs
 ```
 
-包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。
+包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。
 
 启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。
 
diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs
index 49c246df69..f94fb3bf11 100644
--- a/scripts/install-lefthook.mjs
+++ b/scripts/install-lefthook.mjs
@@ -2,7 +2,7 @@
 import { randomUUID } from 'node:crypto'
 import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
 import { spawnSync } from 'node:child_process'
-import { isAbsolute, join, resolve } from 'node:path'
+import { dirname, isAbsolute, join, resolve } from 'node:path'
 
 const MINIMUM_GIT = [2, 26, 0]
 const HOOKS_DIRECTORY = 'dsh-hooks'
@@ -437,6 +437,15 @@ function inspectOwnedHooksDirectory(hooksPath) {
   return { markerPath, ...marker }
 }
 
+function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) {
+  const normalizedHooksPath = normalizedPath(hooksPath)
+  const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some(
+    configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath,
+  )
+  if (!isRegistered) return false
+  return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath
+}
+
 function ensureOwnedHooksDirectory(hooksPath) {
   const inspected = inspectOwnedHooksDirectory(hooksPath)
   if (inspected !== undefined) return inspected
@@ -561,14 +570,22 @@ async function main() {
       'worktree core.hooksPath',
     )
     let ownedHooksDirectory
+    let copiedWorktreePathIsOwned = false
     if (worktreePath !== undefined && worktreePath !== hooksPath) {
       ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
-      if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) {
+      const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath
+      copiedWorktreePathIsOwned = !worktreePathIsRelocated
+        && isRegisteredOwnedHooksPath(commonDirectory, worktreePath)
+      if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) {
         refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
       }
     }
     const directWorktreePathIsOwned = worktreePath !== undefined
-      && (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath)
+      && (
+        worktreePath === hooksPath
+        || ownedHooksDirectory?.hooksPath === worktreePath
+        || copiedWorktreePathIsOwned
+      )
     const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
     if (effectiveEntry !== undefined) {
       const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
@@ -593,6 +610,7 @@ async function main() {
       worktreePath !== undefined
       && worktreePath !== hooksPath
       && ownedHooksDirectory.hooksPath !== worktreePath
+      && !copiedWorktreePathIsOwned
     ) {
       throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
     }
diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts
index 7e30c887ec..a33c245e3d 100644
--- a/scripts/install-lefthook.spec.ts
+++ b/scripts/install-lefthook.spec.ts
@@ -262,6 +262,30 @@ describe('worktree-local Lefthook installer', () => {
     expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
   })
 
+  it('replaces the owned hook path Git copies into a newly added worktree', async () => {
+    const fixture = createFixture()
+    const mainInstall = await runInstaller(fixture, fixture.main)
+    expect(mainInstall.status, mainInstall.stderr).toBe(0)
+    const mainHooks = hooksPath(fixture, fixture.main)
+    const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
+    const lateLinked = join(fixture.container, 'late-linked')
+    git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
+    write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
+    installFakeLefthook(lateLinked)
+    expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
+
+    const linkedInstall = await runInstaller(fixture, lateLinked)
+
+    expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
+    const linkedHooks = hooksPath(fixture, lateLinked)
+    expect(linkedHooks).not.toBe(mainHooks)
+    expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
+    expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain(
+      '# config=late-linked-worktree-config',
+    )
+    expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
+  })
+
   it('serializes concurrent installs and keeps repeated output stable', async () => {
     const fixture = createFixture()
     const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
@@ -509,6 +533,30 @@ describe('worktree-local Lefthook installer', () => {
     expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
   })
 
+  it('does not trust an ownership marker outside a registered worktree hook path', async () => {
+    const fixture = createFixture()
+    const mainInstall = await runInstaller(fixture, fixture.main)
+    expect(mainInstall.status, mainInstall.stderr).toBe(0)
+    const externalHooks = join(fixture.container, 'external-owned-hooks')
+    write(
+      join(externalHooks, '.dsh-lefthook-owned'),
+      `${JSON.stringify({
+        version: 1,
+        owner: 'deepseek-harness worktree-local lefthook hooks',
+        hooksPath: externalHooks,
+      })}\n`,
+      0o600,
+    )
+    git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks])
+
+    const result = await runInstaller(fixture, fixture.linked)
+
+    expect(result.status).toBe(1)
+    expect(result.stderr).toContain('worktree-scoped core.hooksPath')
+    expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks)
+    expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
+  })
+
   it('refuses to activate a sibling worktree dormant hook path', async () => {
     const fixture = createFixture()
     const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json
index 96eaad8c33..96d2dc805f 100644
--- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json
+++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json
@@ -16,11 +16,11 @@
       },
       {
         "role": "user",
-        "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test           # unit tests\npnpm run test:coverage  # unit tests with per-file coverage gates\npnpm run test:e2e       # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all      # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck      # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint           # eslint .\npnpm run lint:fix       # eslint . --fix\npnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog  # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs  # fail if generated relationship docs are stale\npnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid  # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief   # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync       # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph     # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph  # fail if docs/module-graph.md is stale\npnpm run build          # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types  # fail if built declarations are not NodeNext-consumable\npnpm run hygiene        # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n"
+        "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test           # unit tests\npnpm run test:coverage  # unit tests with per-file coverage gates\npnpm run test:e2e       # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all      # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck      # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint           # eslint .\npnpm run lint:fix       # eslint . --fix\npnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog  # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs  # fail if generated relationship docs are stale\npnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid  # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief   # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync       # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph     # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph  # fail if docs/module-graph.md is stale\npnpm run build          # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types  # fail if built declarations are not NodeNext-consumable\npnpm run hygiene        # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n"
       },
       {
         "role": "assistant",
-        "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test           # unit tests\npnpm run test:coverage  # unit tests with per-file coverage gates\npnpm run test:e2e       # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all      # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck      # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint           # eslint .\npnpm run lint:fix       # eslint . --fix\npnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog  # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs  # fail if generated relationship docs are stale\npnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid  # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief   # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync       # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph     # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph  # fail if docs/module-graph.md is stale\npnpm run build          # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types  # fail if built declarations are not NodeNext-consumable\npnpm run hygiene        # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n"
+        "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test           # unit tests\npnpm run test:coverage  # unit tests with per-file coverage gates\npnpm run test:e2e       # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all      # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck      # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint           # eslint .\npnpm run lint:fix       # eslint . --fix\npnpm run doc-typecheck  # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog     # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog  # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc    # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs     # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs  # fail if generated relationship docs are stale\npnpm run verify-md-wrap  # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid  # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv  # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets  # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief   # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync       # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph     # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph  # fail if docs/module-graph.md is stale\npnpm run build          # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types  # fail if built declarations are not NodeNext-consumable\npnpm run hygiene        # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n"
       },
       {
         "role": "user",

From 15ef747cc0625ed52fc841b92483f38d91bb86fe Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Wed, 29 Jul 2026 13:44:26 +0800
Subject: [PATCH 122/153] feat(ui-conversation): implement finalized
 IconActions with clock for user and assistant messages

---
 ...b-message-icon-actions-and-clock.i18n.yaml |  6 ++
 ...7-29-web-message-icon-actions-and-clock.md | 25 +++++++
 ...9-web-message-icon-actions-and-clock.zh.md | 25 +++++++
 .../client/ui-conversation/README.i18n.yaml   |  4 +-
 packages/client/ui-conversation/README.md     |  2 +-
 packages/client/ui-conversation/README.zh.md  |  2 +-
 .../client/chat/AssistantMarkdown.module.css  | 65 ++++++++++++++++-
 .../src/client/chat/AssistantMarkdown.tsx     | 71 +++++++++++++++----
 .../src/client/chat/ChatView.tsx              | 10 ++-
 .../src/client/chat/MessageItem.module.css    |  9 +++
 .../src/client/chat/MessageItem.tsx           | 46 ++----------
 .../src/client/chat/message-chrome.ts         | 66 +++++++++++++++++
 .../tests/chat-branch-tails.spec.tsx          | 67 +++++++++++++++--
 13 files changed, 331 insertions(+), 67 deletions(-)
 create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
 create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
 create mode 100644 .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
 create mode 100644 packages/client/ui-conversation/src/client/chat/message-chrome.ts

diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.i18n.yaml
new file mode 100644
index 0000000000..1800464cd2
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.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-29-web-message-icon-actions-and-clock.md
+2026-07-29-web-message-icon-actions-and-clock.md: ac0c2624d4980f33a01d67cf699259b5913cb274
+2026-07-29-web-message-icon-actions-and-clock.zh.md: 8ec7d8a3d93a51e4a4d315caa067a649ec483120
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
new file mode 100644
index 0000000000..ac0c2624d4
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
@@ -0,0 +1,25 @@
+# Agent Note: Web message IconActions and clocks
+
+Status: implemented
+
+English | [中文](2026-07-29-web-message-icon-actions-and-clock.zh.md)
+
+## Problem
+
+The web chat user bubble already had copy / branch / edit IconActions but no clock. Finalized assistant narration had no under-body action chrome at all, even though the Harness design shows a copy / branch / clock row after the answer settles. Streaming replies must not flash that chrome mid-token.
+
+## Decision
+
+**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`.**
+
+Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helper live in `message-chrome.ts`.
+
+## Alternatives considered
+
+**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.
+
+**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
+
+## Consequences
+
+Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules. Per-message paging remains a deferred footer seat in the package README. Tests pin the three clock shapes, assistant footer presence only when not streaming, and copy payload (text blocks only).
diff --git a/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
new file mode 100644
index 0000000000..8ec7d8a3d9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.zh.md
@@ -0,0 +1,25 @@
+# Agent Note: Web 消息 IconActions 与时钟
+
+Status: implemented
+
+[English](2026-07-29-web-message-icon-actions-and-clock.md) | 中文
+
+## 问题
+
+Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有时钟。已定稿的 assistant 叙述下方完全没有操作栏,尽管 Harness 设计稿在回答结束后展示复制/分支/时钟。流式回复不得在 token 中途闪出该栏。
+
+## 决策
+
+**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟。**
+
+两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。
+
+## 曾考虑的方案
+
+**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
+
+**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
+
+## 后果
+
+已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则。逐消息分页仍是包 README 中的暂缓 footer 座位。测试钉住三种时钟形态、仅在非流式时出现 assistant footer,以及复制载荷(仅 text 块)。
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 49e43861f3..a33f296d5a 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: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051
-README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d
+README.md: 5f5708042639a8c8d87b8f09c8e06a2c87bc7117
+README.zh.md: fd6f8603d056112d62edf18ecfd1bcd3721b1c84
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 85cf040a48..5f57080426 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -32,7 +32,7 @@ None; this package neither assembles nor sends a provider request.
 
 - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
 - **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
-- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
+- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch) ships; branch remains a chrome stub.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
 - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project.
 - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 305258e286..fd6f8603d0 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -32,7 +32,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 
 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
 - **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
-- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
+- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支)已落地;分支仍是 chrome stub。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。
 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
index 9177d721c3..24bc4c0e49 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css
@@ -1,14 +1,22 @@
-/* Assistant flow body: full-width narration (figma 16/28), block gap 16. */
+/* Assistant flow body: full-width narration (figma 16/28), block gap 16.
+   IconActions sit below the body with an explicit 16px top margin (figma
+   43:32997) — separate from the body's internal gap so the footer spacing
+   stays fixed when the body is a single block. */
 
 .root {
   display: flex;
   flex-direction: column;
-  gap: 16px;
   font-size: 16px;
   line-height: 28px;
   color: var(--dsw-alias-label-primary);
 }
 
+.body {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
 /* Interrupted-turn terminal marker: quiet inline tag, no animation. */
 .stopped {
   align-self: flex-start;
@@ -19,3 +27,56 @@
   font-size: 11px;
   line-height: 18px;
 }
+
+/* Finalized footer: copy / branch / clock (figma 43:32997). */
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  height: 28px;
+  margin-top: 16px;
+  /* Optical align with 28px icon hit targets that pad 6px past the glyph. */
+  margin-left: -6px;
+}
+
+/* Clock after the icon buttons; pl 12 separates it from branch. */
+.time {
+  padding-left: 12px;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}
+
+/* Hover-capable pointers: hide until the root is hovered/focused. Touch /
+   hover:none keeps actions visible (opacity:0 still hit-tests). */
+@media (hover: hover) {
+  .actions {
+    opacity: 0;
+    transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
+  }
+
+  .root:hover .actions,
+  .root:focus-within .actions {
+    opacity: 1;
+  }
+}
+
+.action {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
+  border: none;
+  border-radius: 28px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.action:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+}
diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index e3573b14f5..68f5d5e179 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -4,10 +4,15 @@
 // view groups them into tool rows through its keyed toolview slot (figma
 // step-summary flow). Shared by finalized nodes and the streaming partial;
 // the turn-level loading dots live in the chat view's tail, not here.
+// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
 
-import { memo } from 'react'
+import { memo, useCallback } from 'react'
 import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
-import { IconThinkOutline14, JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import {
+  IconBranchOutline16, IconCopyOutline16, IconThinkOutline14,
+  JsonBlock, MarkdownText, Tooltip,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import { formatMessageClock, writeClipboard } from './message-chrome.ts'
 import { ToolRow } from './ToolRow.tsx'
 import css from './AssistantMarkdown.module.css'
 
@@ -16,6 +21,8 @@ export interface AssistantMarkdownProps {
   streaming: boolean
   /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
   interrupted?: boolean | undefined
+  /** Unix epoch ms for the finalized IconActions clock; omitted while streaming. */
+  time?: number | undefined
 }
 
 function firstLine(text: string): string {
@@ -23,6 +30,15 @@ function firstLine(text: string): string {
   return nl === -1 ? text : text.slice(0, nl)
 }
 
+/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
+function copyText(blocks: readonly AssistantBlock[]): string {
+  const parts: string[] = []
+  for (const block of blocks) {
+    if (block.kind === 'text') parts.push(block.text)
+  }
+  return parts.join('')
+}
+
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 function ThinkRow({ text, running }: { text: string; running: boolean }) {
   return (
@@ -38,7 +54,31 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
   )
 }
 
-export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
+/** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */
+function AssistantActions({ text, time }: { text: string; time: number }) {
+  const onCopy = useCallback(() => {
+    void writeClipboard(text)
+  }, [text])
+  return (
+    
+ + + + + + + {formatMessageClock(time)} +
+ ) +} + +export const AssistantMarkdown = memo(function AssistantMarkdown({ + blocks, streaming, interrupted, time, +}: AssistantMarkdownProps) { const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -47,18 +87,23 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea || interrupted === true || blocks.some(block => block.kind !== 'tool-call') if (!hasVisible) return null + // Footer only after the turn settles with a known event time; streaming omits it. + const showActions = !streaming && time !== undefined return (
- {blocks.map((block, i) => { - switch (block.kind) { - case 'text': return - case 'reasoning': return - // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. - case 'tool-call': return null - default: return - } - })} - {interrupted && 已停止} +
+ {blocks.map((block, i) => { + switch (block.kind) { + case 'text': return + case 'reasoning': return + // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. + case 'tool-call': return null + default: return + } + })} + {interrupted && 已停止} +
+ {showActions && }
) }) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index e9503b5677..71ee48bbc6 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -332,7 +332,15 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio } const node: ConversationNode = item.node if (node.kind === 'assistant') { - return + return ( + + ) } if (node.kind === 'command') { return diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 22537f9cfe..a2c14fdc7a 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -27,6 +27,15 @@ height: 28px; } +/* Clock before the icon buttons (figma 388:20051); pr 12 separates it from copy. */ +.time { + padding-right: 12px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + /* Hover-capable pointers: hide until the row is hovered/focused. Touch / hover:none keeps actions visible (opacity:0 still hit-tests). */ @media (hover: hover) { diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 571104dac8..b942937f47 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,5 +1,5 @@ // MessageItem: the four simple node kinds — user bubble (right-aligned, with -// copy / branch / edit IconActions), steering (badged bubble), context +// clock + copy / branch / edit IconActions), steering (badged bubble), context // injection and unknown-surface JSON rows. Props are frozen node slices off // the snapshot cache; memo holds across streaming because unchanged nodes // keep their references. @@ -13,6 +13,7 @@ import { IconBranchOutline16, IconCopyOutline16, IconEditOutline16, JsonBlock, MessageText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' +import { formatMessageClock, writeClipboard } from './message-chrome.ts' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -30,42 +31,6 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown return { text: texts.join(''), rest } } -/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ -async function writeClipboard(text: string): Promise { - // lib.dom types clipboard non-optional, but insecure contexts omit it — - // that runtime gap is exactly what this guard detects. - /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text) - } catch { - // Denied permissions / iframe policy. - } - return - } - // execCommand('copy') is the only clipboard fallback where the async API - // is missing (insecure contexts); deprecated but deliberately retained. - /* eslint-disable @typescript-eslint/no-deprecated */ - const exec = typeof document.execCommand === 'function' - ? document.execCommand.bind(document) - : undefined - if (exec === undefined) return - const el = document.createElement('textarea') - el.value = text - el.setAttribute('readonly', '') - el.style.position = 'fixed' - el.style.left = '-9999px' - document.body.appendChild(el) - el.select() - try { - exec('copy') - } catch { - // Clipboard unavailable; the button stays idle. - } - /* eslint-enable @typescript-eslint/no-deprecated */ - el.remove() -} - /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -98,13 +63,14 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */ -function UserActions({ text }: { text: string }) { +/** User-bubble IconActions (figma 388:20051): clock + copy live; branch/edit stubs. */ +function UserActions({ text, time }: { text: string; time: number }) { const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) return (
+ {formatMessageClock(time)}
- + ) } diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts new file mode 100644 index 0000000000..641399e093 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -0,0 +1,66 @@ +// Shared chrome helpers for user/assistant IconActions rows: clipboard write +// and the compact date+clock label from a session-event epoch. + +/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ +export async function writeClipboard(text: string): Promise { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } + return + } + // execCommand('copy') is the only clipboard fallback where the async API + // is missing (insecure contexts); deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + exec('copy') + } catch { + // Clipboard unavailable; the button stays idle. + } + /* eslint-enable @typescript-eslint/no-deprecated */ + el.remove() +} + +function pad2(n: number): string { + return String(n).padStart(2, '0') +} + +/** + * Compact local timestamp for message IconActions. + * Same calendar day → `HH:mm`; earlier this year → `M月D日 HH:mm`; + * other years → `YYYY年M月D日 HH:mm`. + * @param time - Unix epoch ms from the source session event. + * @param now - Reference instant for the day/year cut (defaults to wall clock). + * @returns Date-aware clock string (24-hour, zero-padded time). + */ +export function formatMessageClock(time: number, now: number = Date.now()): string { + const d = new Date(time) + const n = new Date(now) + const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}` + if ( + d.getFullYear() === n.getFullYear() + && d.getMonth() === n.getMonth() + && d.getDate() === n.getDate() + ) { + return clock + } + const md = `${d.getMonth() + 1}月${d.getDate()}日` + if (d.getFullYear() === n.getFullYear()) return `${md} ${clock}` + return `${d.getFullYear()}年${md} ${clock}` +} 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 bf1266981e..1219b8b4cb 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -11,6 +11,7 @@ import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { formatMessageClock } from '../src/client/chat/message-chrome.ts' import { MessageItem } from '../src/client/chat/MessageItem.tsx' import { PendingCard } from '../src/client/chat/PendingCard.tsx' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' @@ -19,19 +20,24 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx afterEach(cleanup) describe('MessageItem arms', () => { - it('user bubbles expose copy / branch / edit actions; copy writes the text', () => { + it('user bubbles expose clock / copy / branch / edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) + // Same-day clock: construct "today at 14:24" so the label stays `HH:mm`. + const now = new Date() + const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() render( , ) + expect(screen.getByText('14:24')).toBeTruthy() expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy() @@ -51,9 +57,10 @@ describe('MessageItem arms', () => { }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -73,9 +80,10 @@ describe('MessageItem arms', () => { }) render( , ) fireEvent.click(screen.getByRole('button', { name: '复制' })) @@ -113,6 +121,22 @@ describe('MessageItem arms', () => { }) }) +describe('formatMessageClock', () => { + const now = new Date(2026, 6, 29, 10, 0).getTime() + + it('keeps HH:mm on the same calendar day', () => { + expect(formatMessageClock(new Date(2026, 6, 29, 14, 24).getTime(), now)).toBe('14:24') + }) + + it('prefixes month and day across days in the same year', () => { + expect(formatMessageClock(new Date(2026, 0, 1, 14, 24).getTime(), now)).toBe('1月1日 14:24') + }) + + it('prefixes year, month, and day across years', () => { + expect(formatMessageClock(new Date(2025, 11, 31, 9, 5).getTime(), now)).toBe('2025年12月31日 09:05') + }) +}) + describe('small branch tails', () => { it('PendingCard approval reason renders when present', () => { const view = render( @@ -128,6 +152,35 @@ describe('small branch tails', () => { expect(view.getByText('one-liner')).toBeTruthy() }) + it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const now = new Date() + const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime() + const settled = render( + , + ) + expect(settled.getByText('14:24')).toBeTruthy() + expect(settled.getByRole('button', { name: '复制' })).toBeTruthy() + expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy() + fireEvent.click(settled.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('answer body') + settled.unmount() + + const streaming = render( + , + ) + expect(streaming.queryByRole('button', { name: '复制' })).toBeNull() + expect(streaming.queryByText('14:24')).toBeNull() + }) + it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => { // cacheHitPct is null only when input+cacheRead are both zero (pure // output accounting) — any input makes it a real 0%. From d0393106ccf0ebaa193de21091f1e57b469f42dd Mon Sep 17 00:00:00 2001 From: kingwl Date: Wed, 29 Jul 2026 13:51:57 +0800 Subject: [PATCH 123/153] docs: regenerate module graph for the dsh-llm declarations verify-module-graph caught that the plan-mode/tool-tasks dependency fix was not reflected in the generated graph. --- docs/module-graph.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 703f4c8abf..1d458d5b0a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -698,6 +698,7 @@ flowchart TD pkg_plan_mode --> pkg_agent pkg_plan_mode --> pkg_commands pkg_plan_mode --> pkg_invariants + pkg_plan_mode --> pkg_llm pkg_plan_mode --> pkg_session pkg_plan_mode --> pkg_session_projection pkg_plan_mode --> pkg_system_prompt @@ -787,6 +788,7 @@ flowchart TD pkg_tool_pty --> pkg_tools pkg_tool_tasks --> pkg_agent pkg_tool_tasks --> pkg_invariants + pkg_tool_tasks --> pkg_llm pkg_tool_tasks --> pkg_retention pkg_tool_tasks --> pkg_system_prompt pkg_tool_tasks --> pkg_tasks @@ -1083,7 +1085,7 @@ flowchart TD | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`tools`](../packages/core/tools) | -| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | @@ -1099,7 +1101,7 @@ flowchart TD | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | From 4aa1aa4a66eff99d48b3cb7808d8130afb309b96 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 14:30:09 +0800 Subject: [PATCH 124/153] test(web): refresh aria goldens for message IconActions and clocks Settled history now exposes user/assistant chrome (including date-aware clocks) in the accessibility tree; collapse clocks via scaffold and update keyless scenario goldens. --- .../snapshots/code-mode-round/ui.expected.md | 13 +++++-- .../cordis-tool-round/ui.expected.md | 23 ++++++++++-- .../snapshots/fresh-round-trip/ui.expected.md | 13 +++++-- .../lifecycle-chrome/reloaded.expected.md | 8 +++-- .../live-interactions/cancel.expected.md | 9 +++-- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 8 +++-- .../snapshots/seeded-history/ui.expected.md | 36 ++++++++++++++----- .../snapshots/steering/mid-steer.expected.md | 11 +++--- .../snapshots/steering/settled.expected.md | 13 +++++-- 10 files changed, 108 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 4847924619..8701c6fb19 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop." +- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": @@ -16,6 +16,11 @@ - img - img - text: "Think The user wants me to write a single `run_code` program that:" +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img @@ -28,7 +33,11 @@ - img - text: Think The program ran successfully. Let me now reply DONE as instructed. - paragraph: DONE -- text: cache hit 52% · 17,490 tokens · 1 turns · 2 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index e5e5626be3..77038546c0 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop." +- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": @@ -16,6 +16,11 @@ - img - img - text: "Think The user wants me to:" +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img @@ -24,6 +29,11 @@ - img - img - text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code." +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button [expanded]: - img - text: Mount temporary Plugin typescript @@ -33,6 +43,11 @@ - img - img - text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id." +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img @@ -42,7 +57,11 @@ - img - text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop. - paragraph: CORDIS_UI_DONE -- text: cache hit 77% · 66,813 tokens · 1 turns · 4 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 6a827420c4..7ea4c95e2b 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." +- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}" - button "复制": - img - button "在新对话中分支": @@ -16,6 +16,11 @@ - img - img - text: Think The user wants me to run a simple bash command and reply with "DONE". +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - img - text: Bash Echo the test string - button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".": @@ -23,7 +28,11 @@ - img - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE". - paragraph: DONE -- text: cache hit 99% · 15,818 tokens · 1 turns · 2 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 33d1f7e6bf..17f79759ca 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Reply with the single word LIGHTHOUSE and stop. +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "复制": - img - button "在新对话中分支": @@ -17,7 +17,11 @@ - img - text: Think The user wants me to reply with a single word. Let me comply. - paragraph: LIGHTHOUSE -- text: cache hit 99% · 7,810 tokens · 1 turns · 1 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 3d092b17ec..665221e6b4 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Reply with a one-sentence description of event sourcing, then stop. +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img - button "在新对话中分支": @@ -13,7 +13,12 @@ - button "编辑": - img - paragraph: partial -- text: 已停止 0 tokens · 1 turns · 1 steps +- text: 已停止 +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} 0 tokens · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 5272bcf2d1..27b4a6c1c1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Reply with a one-sentence description of event sourcing, then stop. +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img - button "在新对话中分支": diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 5935872557..9e051feea3 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Reply with a one-sentence description of event sourcing, then stop. +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} - button "复制": - img - button "在新对话中分支": @@ -17,7 +17,11 @@ - img - text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls. - paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. -- text: cache hit 99% · 7,869 tokens · 1 turns · 1 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index 4f9181f702..db71bd6696 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -1,32 +1,50 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] - - text: · 1 turns - tablist: - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop." +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img - button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.": + - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- button: +- button "复制": - img -- text: Read a.txt -- button: +- button "在新对话中分支": - img -- text: Read b.txt +- text: {{clock}} +- img +- text: Read +- button "a.txt" +- img +- text: Read +- button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": + - img - img - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed. - paragraph: DONE -- text: cache hit 98% · 15,962 tokens · 1 turns · 2 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img +- text: Danger Full Access - combobox "Access mode": - - option "Read-only" [selected] - - option "Read-write" + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] - button "选择模型,当前 deepseek-v4-flash": - text: deepseek-v4-flash - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 8d33ea6283..87ed907abd 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "复制": - img - button "在新对话中分支": @@ -16,12 +16,15 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img -- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 等待回答(1 题)" -- button "▸ 问题内容" -- text: cache hit 98% · 7,946 tokens · 1 turns · 1 steps +- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} cache hit 98% · 7,946 tokens · 1 turns · 1 steps" - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index f08fc518e8..7a2f9ae0e6 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -5,7 +5,7 @@ - tab "Chat" [selected] - tab "Trajectory" - tab "Waterfall" -- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. +- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "复制": - img - button "在新对话中分支": @@ -16,6 +16,11 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} - button: - img - img @@ -25,7 +30,11 @@ - img - text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer. - paragraph: Great, let's move forward. BANANA! -- text: cache hit 98% · 15,967 tokens · 1 turns · 2 steps +- button "复制": + - img +- button "在新对话中分支": + - img +- text: {{clock}} cache hit 98% · 15,967 tokens · 1 turns · 2 steps - textbox "Message the agent" - button "Add attachment": - img From f378873b22e8142c8625f42664f5cc3eee93cca4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 29 Jul 2026 14:39:21 +0800 Subject: [PATCH 125/153] fix(web): replay cursor movements the way a terminal paints them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carriage return and backspace only MOVE the cursor; neither erases. Both of my earlier approximations were wrong, and I checked each case against a real terminal rather than reasoning about it: `100%\rOK` shows `OK0%`, not `OK` — the redraw is shorter than the frame beneath it, so the tail stands. `abc\b` still shows `abc`, not `ab` — a trailing backspace has nothing to overwrite. `\x1b[31mgone\rkept` paints `kept` RED, because a carriage return does not reset the graphic state, which one of my own tests had asserted the opposite of. Both now replay into a per-line column buffer with SGR state stamped per column, as a terminal stores it per cell. That gives the partial-overwrite case its real result too: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red, since `ok` reached only two of the three cells. The presenter description now also renders at every site. An expanded row draws it itself — the collapsed summary is hidden while open, so otherwise the description was visible only collapsed, the opposite of "above the card" — and the details panel draws it above the card as well. Three of my own tests encoded the wrong semantics and were corrected with their behavior, and the emit loop's gap-filling arm was removed as unreachable: `\r` and backspace only move left, so no column can be unwritten. --- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 4 +- .../2026-07-28-web-terminal-card.zh.md | 4 +- .../src/client/chat/ToolRow.module.css | 8 ++ .../src/client/chat/ToolRow.tsx | 6 + .../client/skeleton/DetailsPanel.module.css | 8 ++ .../src/client/skeleton/DetailsPanel.tsx | 13 +- .../tests/terminal-card.spec.tsx | 24 ++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/ansi.ts | 136 +++++++++--------- .../client/ui-primitives/tests/ansi.spec.ts | 52 +++++-- 13 files changed, 182 insertions(+), 85 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 0d805c8de0..6927b58937 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.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-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 87cb40331bca6a1bfcdd4e8ecd9bbe69c6572be0 -2026-07-28-web-terminal-card.zh.md: ab5a35d6f76427cf1e0338d8491bd3485cb41302 +2026-07-28-web-terminal-card.md: 284c91cc936c1e757f20f0d0d8afbe4b4c0afc42 +2026-07-28-web-terminal-card.zh.md: 3ffcf232942eed883f44da583c68455715d347ce diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 87cb40331b..284c91cc93 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -12,7 +12,7 @@ The Web client ignored it. `packages/client/ui-conversation/src/client/contract/ ## Decision -`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. +`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means. The component's contract: @@ -20,7 +20,7 @@ The component's contract: - **One run-state dot for the call, on the first row.** `StateDot` in three of its four states: the chase while running, red for the exit status that also renders the pill, green for a clean settle — the same indicator a tool row's leading icon uses, so a row and its own card cannot disagree about one command. The dot exists because the first question a reader has about a shell command is whether it is still running, and without it that had to be inferred from the absence of output — which a settled command producing no output also looks like. It sits out of flow in a gutter reserved to the left of the card surface, so it neither indents its command nor depends on the command's own text metrics to line up with it. Exactly one dot, whatever the line count: the exit status the view carries is the whole call's, and bash reports no per-command status, so a dot per line would assert of a line that succeeded inside a failing call that the line itself failed. The single visually hidden text label carries the same scope, since `StateDot` is `aria-hidden` and one label per row would read to assistive technology as several distinct outcomes. - **No soft wrapping.** Output lines are `white-space: pre` inside a horizontally scrolling box. Column alignment survives; a long line scrolls instead of folding. - **Height cap with an expand control.** Output longer than `DEFAULT_TERMINAL_MAX_LINES` (16) lines shows `ceil(max/2)` head lines plus the remaining tail lines, with a button in between that reports the hidden count and expands. The count is of parsed lines after the trailing output terminator is dropped, so an N-line output ending in a newline is N lines. The split arithmetic is the same as the TUI transcript's collapsed tool card (`packages/ui/tui/src/components/transcript.ts`), so one command's head and tail slices agree between the two front ends. -- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Two cursor movements resolve before that strip, because their effect on the visible text has to land before the characters expressing them are dropped: a carriage return reduces its line to the final redraw, and a backspace overwrites the character before it, so `abc` followed by two backspaces and `XY` reads `aXY` as a terminal draws it. Both are per-line, so neither reaches across a newline. A backspace steps over CSI sequences rather than erasing their bytes: a sequence moves no cursor, and eating part of one would corrupt it and repaint everything after with whatever the mangled remainder parses as, so it walks back to the last PRINTED character and drops that instead — the surviving text keeps the color its run authored. +- **ANSI color.** `anser` splits the SGR runs; `ui-primitives/src/ansi.ts` resolves each run into an inline style rendered as React spans. A foreground-only run maps the basic 16 colors onto `--dsw-*` theme tokens so authored color stays legible under both themes; a run that paints its own background keeps anser's literal rgb for both so its intended contrast survives, as do 256-palette, truecolor, and the two basic colors this design system has no token for. Sequences that carry no color (OSC strings, non-CSI escapes, inert C0 controls) are stripped before parsing so they never reach the DOM as literal characters. Cursor movements resolve before that strip, into a per-line column buffer rather than by string surgery, because carriage return and backspace only MOVE the cursor — neither erases anything, so what a reader sees is whatever each column last had written to it. `100%` then a carriage return and `OK` shows `OK0%`, since the redraw is shorter than the frame beneath it; a trailing `abc` plus a backspace still shows `abc`, since nothing overwrote the `c`; `abc` plus two backspaces and `XY` shows `aXY`. Each of these was checked against a real terminal, because the earlier truncate-and-delete approximations looked right and were not. SGR state is stamped per column as a terminal stores it per cell, so a partial overwrite keeps each surviving character's own color: red `bad`, three backspaces, then `ok` shows `okd` with the `d` still red. A CSI sequence occupies no column and changes only the state later writes are stamped with, which is also why a carriage return does not reset color. - **Exit status and copy.** A non-zero exit code or a signal renders a status pill, matching the exit-status distinction the bash tool's own renderer draws; a clean exit renders none, and settled empty output renders a dimmed placeholder. The copy control copies the raw output text, not the rendered tree, so the prompt line and the pill stay out of the clipboard. Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced code block match visually; `white-space: pre` plus horizontal scroll is the deliberate divergence. The clipboard write both components need moved out of `CodeBlock` into a package-internal `src/clipboard.ts`, unexported so it stays an implementation detail of the two blocks. diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md index ab5a35d6f7..3ffcf23294 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md @@ -12,7 +12,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c ## Decision -`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。 +`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。 该组件的契约: @@ -20,7 +20,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c - **整次调用一枚运行状态点,位于第一行。** 它是 `StateDot` 四种状态中的三种:运行期间为追逐动画,与渲染状态徽章相同的退出状态为红色,干净落定为绿色——与工具行行首图标使用同一个指示器,因此一行与其自身的卡片不可能对同一条命令产生分歧。该状态点存在的理由是:读者对一条 shell 命令的第一个问题就是它是否仍在运行;没有它时,这一点只能从「没有输出」推断,而一条落定后无输出的命令看起来也一样。它以脱离文档流的方式落在卡片表面左侧预留的落区里,因此既不会缩进其命令,也不依赖命令自身的文本度量来与之对齐。无论有多少行,都只有一枚:视图携带的退出状态属于整次调用,而 bash 不报告逐条命令的状态,因此每行一枚状态点就等于在断言——一条在失败调用中其实成功了的命令行自身失败了。那一处视觉隐藏的文本标签具有相同的作用域,因为 `StateDot` 是 `aria-hidden`,而每行一个标签会被辅助技术读成好几个各自独立的结果。 - **不软换行。** 输出行使用 `white-space: pre`,置于横向滚动的容器内。列对齐得以保留;长行滚动,而非折行。 - **高度上限与展开控件。** 输出超过 `DEFAULT_TERMINAL_MAX_LINES`(16)行时,显示 `ceil(max/2)` 行首部加余下的尾部行数,中间是一个按钮,报告被隐藏的行数并可展开。计数针对的是剥除输出末尾终止符之后解析出的行,因此以换行结尾的 N 行输出就是 N 行。切分算法与 TUI transcript 折叠态工具卡片(`packages/ui/tui/src/components/transcript.ts`)完全一致,因此同一条命令的首尾切片在两个前端之间吻合。 -- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。两种光标移动在该剥除之前先行结算,因为它们对可见文本的作用必须先落地,之后才能丢弃表达它们的那些字符:回车把所在行归约为最后一次重绘,退格覆盖它前面的字符——于是 `abc` 后接两个退格再接 `XY` 读作 `aXY`,与终端的绘制一致。两者都按行结算,因此都不会跨越换行。退格会跨过 CSI 序列,而不是擦掉它的字节:序列本身不移动光标,吃掉它的一部分会破坏该序列,并让其后的一切按被损坏的残余重新着色,因此退格回退到最后一个**已打印**字符并删除它——存活下来的文本保留其所在分段所声明的颜色。 +- **ANSI 颜色。** `anser` 切分 SGR 分段;`ui-primitives/src/ansi.ts` 把每段解析为内联样式,渲染成 React span。只设前景色的分段把基本 16 色映射到 `--dsw-*` 主题 token,使作者指定的颜色在两种主题下都可读;自行绘制背景的分段则前后景都保留 anser 给出的字面 rgb,以保住它意图中的对比度,256 色板、truecolor 以及本设计系统没有对应 token 的两种基本色同样如此。不承载颜色的转义序列(OSC 串、非 CSI 转义、无显示意义的 C0 控制符)在解析前被剥除,因此绝不会以字面字符抵达 DOM。光标移动在该剥除之前先行结算,且落在逐行的列缓冲里而不是靠字符串手术,因为回车与退格**只移动**光标——两者都不擦除任何东西,所以读者看到的就是每一列最后被写入的内容。`100%` 后接回车再接 `OK` 显示为 `OK0%`,因为这次重绘比它下面的帧更短;末尾 `abc` 加一个退格仍显示 `abc`,因为没有任何东西覆盖过那个 `c`;`abc` 加两个退格再接 `XY` 显示 `aXY`。这些用例都对照真实终端核实过,因为先前「截断加删除」的近似看起来是对的,实际并不对。SGR 状态按列打戳,与终端按单元格存储颜色的方式一致,因此部分覆盖会保留每个存活字符自身的颜色:红色 `bad`、三个退格、再写 `ok`,显示为 `okd` 且那个 `d` 仍是红的。CSI 序列不占列,只改变后续写入被打上的状态——这也正是回车不会重置颜色的原因。 - **退出状态与复制。** 非零退出码或信号渲染一枚状态徽章,与 bash 工具自身渲染器所作的退出状态区分一致;干净退出不渲染徽章,落定后的空输出渲染一处变暗的占位文字。复制控件复制的是原始输出文本而非渲染后的树,因此提示符行与徽章不会进入剪贴板。 几何尺寸、圆角与字体沿用 `CodeBlock`,因此终端卡片与围栏代码块在视觉上一致;`white-space: pre` 加横向滚动是有意的分歧。两个组件都需要的剪贴板写入从 `CodeBlock` 中提取到包内部的 `src/clipboard.ts`,不对外导出,因此它仍是这两个块的实现细节。 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 bf1e238e23..0c1f674262 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -182,6 +182,14 @@ button.leading { also replaces each primitive's own standalone vertical spacing with the flow's row rhythm. */ .codeBody, +/* Indented to the terminal body's own column, so the description reads as the + card's heading rather than as another summary row. */ +.terminalDescription { + margin: 4px 0 0 22px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + .terminalBody { margin: 4px 0 4px 22px; } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 30ef595397..d4fae4f6c5 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -156,6 +156,12 @@ 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' diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index ef4173735e..0ddc58e30b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -95,6 +95,14 @@ /* The terminal card sits directly under its section label, so it drops the primitive's standalone vertical margin; the section owns the spacing. */ +/* Above the card, which is where the render-intent contract puts a terminal + call's description; the panel has no summary row to carry it. */ +.terminalDescription { + margin: 0 0 6px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); +} + .terminal { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 630334eae7..9fc5a04ff6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -135,7 +135,18 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo */ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) { const terminal = terminalCardModel(material.block, cwd) - if (terminal !== null) return + if (terminal !== null) { + // The contract renders the presenter's description above the card, and the + // panel has no summary row to carry it, so it is drawn here. + return ( + <> + {terminal.description !== undefined && ( +
{terminal.description}
+ )} + + + ) + } // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index dd096c6e91..3ac9ad2f1c 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/packages/client/ui-conversation/tests/terminal-card.spec.tsx @@ -267,6 +267,19 @@ describe('chat row terminal body', () => { expect(view.queryByText('List files')).toBeNull() }) + it('keeps the presenter description visible once the terminal card is expanded', () => { + // The contract puts the description ABOVE the card. The collapsed summary is + // hidden while a row is open, so an expanded terminal row has to draw it + // itself or the description would only ever be visible collapsed. + const view = render() + expect(view.getByText('Terminal 3')).toBeTruthy() + fireEvent.click(view.container.querySelector('button')!) + expect(view.container.querySelector('[data-terminal]')).not.toBeNull() + expect(view.getByText('Terminal 3')).toBeTruthy() + }) + it('a running terminal call expands to the prompt line with no output yet', () => { const view = render() fireEvent.click(view.container.querySelector('button')!) @@ -421,6 +434,17 @@ describe('DetailsPanel Output section', () => { expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy() }) + it('renders the presenter description above the card', () => { + const view = mount(snapshot({ + nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })], + }), target) + const description = view.getByText('Terminal 3') + const card = view.container.querySelector('[data-terminal]') + expect(card).not.toBeNull() + // Above, not below: document order is what places it as the card's heading. + expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + it('resolves the prompt cwd against the session workspace', () => { const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app') // No workdir in the call view: the prompt label is the workspace basename. diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index ad22a9f734..3e81b076a4 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 4222aac4fa1d9c89a3d3c702ee8391fba1eef178 -README.zh.md: e7a5b5ad57048bc59872963b0068c6f88fc772f8 +README.md: 3e77cb1955972db602b4e751a52a45cf5f5f349d +README.zh.md: 19e02c3e466afcbddd0d19ad6644887d3a53ca10 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4222aac4fa..3e77cb1955 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage-return redraws and backspace overwrites resolve as a terminal performs them before inert controls are stripped; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter to the left of the card surface. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; carriage return and backspace replay into a per-line column buffer before inert controls are stripped, since both only move the cursor (so `100%` + CR + `OK` shows `OK0%`), with SGR state stamped per column as a terminal stores it per cell; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e7a5b5ad57..19e02c3e46 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;回车重绘与退格覆盖会按终端的行为先行结算,之后才剥除无显示意义的控制符;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片表面左侧的落区中。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;回车与退格在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为两者都只移动光标(所以 `100%` 加回车再加 `OK` 显示为 `OK0%`),且 SGR 状态按列打戳,与终端按单元格存储颜色一致;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/ansi.ts b/packages/client/ui-primitives/src/ansi.ts index 115d3faffc..1d7dd87796 100644 --- a/packages/client/ui-primitives/src/ansi.ts +++ b/packages/client/ui-primitives/src/ansi.ts @@ -82,95 +82,99 @@ const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g /** * C0 controls with no display meaning here. Tab, newline, backspace and ESC - * survive: the first two for layout, backspace for its overwrite, ESC for - * anser's CSI split. + * survive: the first two for layout, backspace for the cursor replay, ESC + * for anser's CSI split. */ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g /** - * Apply carriage-return redraws: within a line, only the text after the last - * `\r` survives, which is what a terminal shows for progress output. A `\r` - * that only terminates a CRLF line is dropped first so those lines keep - * their text. SGR codes preceding a dropped redraw are dropped with it. - * @param text - output text, already free of OSC and non-CSI escapes. - * @returns the text with each line reduced to its final redraw. - */ -function applyCarriageReturns(text: string): string { - return text.split('\n').map((raw) => { - const line = raw.replace(/\r+$/, '') - return line.slice(line.lastIndexOf('\r') + 1) - }).join('\n') -} - -/** - * Apply backspaces as the cursor-left-then-overwrite a terminal performs, so - * `abc` followed by two backspaces and `XY` reads `aXY` instead of keeping the - * characters it overwrote. Progress meters and captured PTY output use - * backspace this way. Resolved per line, so a backspace neither eats the - * newline before it nor reaches into the previous line's tail; one at a line - * start has nothing to erase. - * @param text - output text, already reduced to its carriage-return redraws. - * @returns the text with each backspace resolved against the character before it. - */ -function applyBackspaces(text: string): string { - if (!text.includes('\u0008')) return text - return text.split('\n').map(applyBackspacesToLine).join('\n') -} - -/** - * One line's backspaces, resolved over VISIBLE characters only. A CSI sequence - * moves no cursor, so it must survive intact: erasing its bytes would corrupt - * the sequence and repaint the rest of the output with whatever the mangled - * remainder parses as. The sequences are therefore held as indivisible units - * that a backspace steps over on its way to the last printed character, and a - * unit already erased stays erased so a run's own color still applies to what - * remains of it. + * Replay one line's cursor movements the way a terminal paints it, into a + * column buffer. Carriage return and backspace only MOVE the cursor — neither + * erases anything — so what a reader sees is whatever each column last had + * written to it. That distinction is the whole point of doing this as a buffer + * rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is + * shorter than the frame beneath it, and a trailing `abc\b` still shows `abc` + * because nothing ever overwrote the `c`. + * + * A CSI sequence occupies no column; it changes the state that the NEXT writes + * are stamped with, which is how a terminal stores color per cell. `red bad` + * then three backspaces then `ok` therefore shows `okd` with the `d` still red: + * `ok` overwrote two cells and the third kept the state it was written with. + * The columns are re-emitted as runs, so anser sees that same styling. * @param line - one output line, still carrying its CSI sequences. - * @returns the line with each backspace applied to the character before it. + * @returns the line as the terminal would have it after every movement. */ -function applyBackspacesToLine(line: string): string { - if (!line.includes('\u0008')) return line - const units: { text: string; visible: boolean }[] = [] - // Same shape anser splits on: CSI ... final byte. Matched here so a sequence - // is one unit rather than a run of erasable characters. +function replayLine(line: string): string { + // Same shape anser splits on, so a sequence is one unit here as well. const csi = /\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/g + /** Per column: the SGR state in force when it was written, and its character. */ + const columns: { sgr: string; char: string }[] = [] + let cursor = 0 + // SGR state accumulates as the line is scanned, exactly as a terminal tracks + // it: each cell is stamped with whatever was in force at the moment of the + // write, so a later redraw cannot restyle the cells it does not reach. + let sgr = '' let at = 0 + + const consume = (chunk: string): void => { + for (const char of chunk) { + if (char === '\r') { cursor = 0; continue } + if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue } + columns[cursor] = { sgr, char } + cursor++ + } + } + for (const match of line.matchAll(csi)) { - for (const char of line.slice(at, match.index)) units.push({ text: char, visible: true }) - units.push({ text: match[0], visible: false }) + consume(line.slice(at, match.index)) + // A reset clears the accumulated state; anything else adds to it. + sgr = /^\u001b\[0?m$/.test(match[0]) ? '' : sgr + match[0] at = match.index + match[0].length } - for (const char of line.slice(at)) units.push({ text: char, visible: true }) + consume(line.slice(at)) - const kept: { text: string; visible: boolean }[] = [] - for (const unit of units) { - if (unit.visible && unit.text === '\u0008') { - // Walk back past any escapes to the last printed character and drop it, - // keeping those escapes so the surviving text stays styled as authored. - for (let index = kept.length - 1; index >= 0; index--) { - if (kept[index]?.visible !== true) continue - kept.splice(index, 1) - break - } - continue + // Re-emit the columns, opening a run only where its SGR state changes and + // closing the previous one, so anser sees the same styling a terminal shows. + // No index can be missing: `\r` and backspace only move the cursor LEFT, so + // every column up to the furthest write has been written at least once. + let out = '' + let active = '' + for (const column of columns) { + if (column.sgr !== active) { + if (active !== '') out += '\u001b[0m' + out += column.sgr + active = column.sgr } - kept.push(unit) + out += column.char } - return kept.map(unit => unit.text).join('') + return active === '' ? out : `${out}\u001b[0m` +} + +/** + * Replay every line's cursor movements. A `\r` that only terminates a CRLF line + * is dropped first, so those lines keep their text instead of being redrawn onto + * themselves. + * @param text - output text, already free of OSC and non-CSI escapes. + * @returns the text with each line painted as the terminal would. + */ +function applyCursorMovements(text: string): string { + return text.split('\n') + .map(raw => raw.replace(/\r+$/, '')) + .map(line => (/[\r\u0008]/.test(line) ? replayLine(line) : line)) + .join('\n') } /** * Remove every escape sequence and control character that carries no color, - * leaving CSI sequences for anser and `\n`/`\t` for layout. Carriage-return - * redraws and backspace overwrites resolve first: both are cursor movements - * whose effect on the visible text must land before the characters that - * expressed them are dropped. + * leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements + * (carriage return, backspace) replay first, since their effect on the visible + * text must land before the characters that expressed them are dropped. * @param text - raw command output. * @returns text whose only remaining escapes are CSI sequences. */ function sanitize(text: string): string { const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '') - return applyBackspaces(applyCarriageReturns(escaped)).replace(INERT_CONTROL, '') + return applyCursorMovements(escaped).replace(INERT_CONTROL, '') } /** diff --git a/packages/client/ui-primitives/tests/ansi.spec.ts b/packages/client/ui-primitives/tests/ansi.spec.ts index 76af75a565..801484d6a7 100644 --- a/packages/client/ui-primitives/tests/ansi.spec.ts +++ b/packages/client/ui-primitives/tests/ansi.spec.ts @@ -151,8 +151,26 @@ describe('parseAnsiLines: carriage returns', () => { expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined }) }) - it('drops the SGR codes that preceded a discarded redraw', () => { - expect(onlySpan(`${ESC}[31mgone\rkept`)).toEqual({ text: 'kept', style: undefined }) + it('leaves the tail of a longer frame standing under a shorter redraw', () => { + // Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage + // return only moves the cursor, so the two columns the redraw never reaches + // still hold the frame beneath — truncating to the last `\r` would lose them. + expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined }) + expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined }) + }) + + it('clamps a backspace run at the line start rather than going negative', () => { + // More backspaces than characters: the cursor stops at column 0, so the + // following write simply overwrites from there. + expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined }) + }) + + it('keeps SGR state in force across a redraw, as a terminal does', () => { + // Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED. + // A carriage return moves the cursor; it does not reset the graphic state, + // so the redraw inherits the color the discarded frame was written with. + expect(onlySpan(`${ESC}[31mgone\rkept`)) + .toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } }) }) it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => { @@ -184,6 +202,17 @@ describe('parseAnsiLines: backspaces', () => { ]) }) + it('treats a trailing backspace as a cursor move, not a delete', () => { + // Verified against a real terminal: `abc\b` still shows `abc`. Only a later + // write overwrites; a backspace with nothing after it erases nothing. + expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined }) + // Same at a line boundary: the newline ends the line before any overwrite. + expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([ + [{ text: 'abc', style: undefined }], + [{ text: 'def', style: undefined }], + ]) + }) + it('steps over an SGR sequence instead of erasing its bytes', () => { // `abc` reset then two backspaces then `XY`: erasing the reset's bytes would // corrupt it and repaint the rest of the line with whatever the remainder @@ -202,14 +231,21 @@ describe('parseAnsiLines: backspaces', () => { ]]) }) - it('applies the overwrite after a carriage-return redraw, not before', () => { - // The redraw wins first; the backspace then erases inside what survived. - expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'ne', style: undefined }) + it('replays a redraw and a trailing backspace as pure cursor moves', () => { + // Verified against a real terminal: `old\rnew\b` shows `new`. The redraw + // repaints all three columns and the trailing backspace only moves the + // cursor left — nothing overwrites the `w`, so nothing is lost. + expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined }) }) - it('keeps the run\'s style while erasing its own characters', () => { - expect(onlySpan(sgr('31', `bad${BS}${BS}${BS}ok`))) - .toEqual({ text: 'ok', style: { color: 'var(--dsw-alias-state-error-primary)' } }) + it('overwrites only the columns the later write reaches, keeping the rest styled', () => { + // Verified against a real terminal: red `bad`, three backspaces, then `ok` + // shows `okd` — the cursor returned to column 0 and `ok` overwrote two of + // the three columns, so the untouched `d` keeps the run's red. + expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[ + { text: 'ok', style: undefined }, + { text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } }, + ]]) }) }) From 972b3f3a30fa587ac85603b8a0611d0d86c92b5a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 14:47:26 +0800 Subject: [PATCH 126/153] fix(ui-conversation): document writeClipboard @param for export JSDoc gate --- .../client/ui-conversation/src/client/chat/message-chrome.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/src/client/chat/message-chrome.ts b/packages/client/ui-conversation/src/client/chat/message-chrome.ts index 6e912c3e14..cf376473d0 100644 --- a/packages/client/ui-conversation/src/client/chat/message-chrome.ts +++ b/packages/client/ui-conversation/src/client/chat/message-chrome.ts @@ -1,7 +1,10 @@ // Shared chrome helpers for user/assistant IconActions rows: clipboard write // and the compact date+clock label from a session-event epoch. -/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */ +/** + * Best-effort clipboard write; rejections stay swallowed (no success chrome). + * @param text - Plain text to place on the clipboard. + */ export async function writeClipboard(text: string): Promise { // lib.dom types clipboard non-optional, but insecure contexts omit it — // that runtime gap is exactly what this guard detects. From 66d650e4fb3bb06bfe073f563ec5c2d4527ce510 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:52:41 +0800 Subject: [PATCH 127/153] refactor: simplify sidebar logics --- ...29-web-details-session-lifecycle.i18n.yaml | 4 +- ...026-07-29-web-details-session-lifecycle.md | 16 +++--- ...-07-29-web-details-session-lifecycle.zh.md | 16 +++--- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- .../tests/details-session-lifecycle.e2e.ts | 52 +++++++++++-------- apps/web/tests/lifecycle-chrome.e2e.ts | 8 --- apps/web/tests/smoke-real.e2e.ts | 4 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 6 +-- packages/client/ui-layout/README.zh.md | 6 +-- .../client/ui-layout/src/client/AppFrame.tsx | 30 ++++------- .../client/ui-layout/src/client/columns.ts | 6 +-- .../client/ui-layout/src/client/stores.ts | 15 +++--- packages/client/ui-layout/src/invariant.ts | 4 +- .../client/ui-layout/tests/app-frame.spec.tsx | 37 ++++++------- .../ui-layout/tests/layout-store.spec.ts | 22 ++++---- 18 files changed, 113 insertions(+), 125 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml index 5ba31e77f2..060de23ba3 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.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/bug-fix/2026-07-29-web-details-session-lifecycle.md -2026-07-29-web-details-session-lifecycle.md: 3483720ef642e87bf2f3ffa0d4cf9677711a3354 -2026-07-29-web-details-session-lifecycle.zh.md: 7570f4ad045be7607beb98295551bb50403620c3 +2026-07-29-web-details-session-lifecycle.md: d9e0255768f165bed0631b9324e971b57ec7dcae +2026-07-29-web-details-session-lifecycle.zh.md: 09452ba80ff240ddca76df239b40ea661566f8e2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md index 3483720ef6..d9e0255768 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.md @@ -6,24 +6,24 @@ English | [中文](2026-07-29-web-details-session-lifecycle.zh.md) ## Problem -The details entry is Session-scoped, but its grid width is root-scoped and persisted. Changing the current Session replaced or removed the details content without closing that root column, so New Session could show its composer beside an empty details panel that still consumed 360 pixels. The same ownership gap applied to ordinary Session switches and to selection invalidation after a Session disappeared. +The details entry is Session-scoped, but its preferred grid width is root-scoped. Selecting a different Session replaced the details content without closing that root preference, so the new owner inherited stale viewing geometry. Hero and other unselected states render no Session-scoped details; they need a derived zero track without becoming false owners in the comparison. ## Decision -`AppFrame` derives one details owner from the authoritative Session projection after the Session baseline is ready: the current Session must still exist and must not be blank. The first ready active Session is baseline restoration, so an open details width may survive a browser refresh. A first ready New Session state has no details owner and closes stale persisted state. +`AppFrame` reads the current Session id and its `blank` summary flag from the authoritative Session projection. It records the last non-blank selected id only when that Session can own details, so hero and other unselected states neither trigger closure nor replace the last Session owner; their rendered details track derives as zero without changing the stored preference. The first Session keeps the default details width; returning to the same Session restores its current width; selecting a different Session closes the root-scoped details preference through the layout store before paint. The per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). -After baseline restoration, every details-owner change closes the panel through the layout store before paint. This covers active-to-active navigation, active-to-blank New Session, clearing the current selection, and invalidation after deletion. Returning to the earlier Session keeps details closed because the root store records the close; the per-Session chat selection remains owned by the session-scoped store described by the [slot system standard](../architecture/2026-07-22-slot-type-chain-implementation.md). - -Manual close and reopen inside one unchanged active Session retain their existing behavior. The lifecycle effect changes neither sidebar actions nor the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, or concession-chain resizing. +The layout store is transient and starts details at its default width. It neither reads nor writes `localStorage`, so reload resets both panel widths and needs no Session-baseline exception. Manual close and reopen inside one unchanged Session retain their existing behavior. The lifecycle effect changes neither the [Workspace-owned New Session flow](../feature/2026-07-25-workspace-ui-product-flow.md), composer drafts, Session navigation, nor concession-chain resizing. ## Alternatives considered -**Close details in the New Session click handler.** Rejected because top-level New Session, Workspace row actions, the Workspace picker, ordinary Session rows, and removal can all change the owner. An entry-point patch would leave the shared lifecycle inconsistent. +**Close details in the New Session click handler.** Rejected because an unselected surface has no Session-scoped details and must not mutate geometry. Closure belongs to the later comparison between two defined Session owners. **Persist panel geometry per Session.** Rejected because the product contract needs stale context removed, not a new map of remembered widths. Per-Session geometry would also reopen details when users return, contrary to the chosen close-on-leave behavior. -**Only hide the details component when no Session is current.** Rejected because a blank Session is still current, and removing content without zeroing the grid track is the reported defect. +**Preserve persisted layout after the Session baseline is ready.** Rejected because it duplicates startup lifecycle in a presentation component solely to validate stale viewing state. Transient defaults make reload deterministic without a readiness flag. + +**Treat every current-projection change as a Session switch.** Rejected because startup materialization, hero, clearing selection, and invalidation are not transitions between two Session owners. ## Consequences -Leaving an active Session forgets any dragged details width, since the existing close action writes zero and reopening uses the contract default. Refreshing an active Session preserves its open panel, while refreshing New Session clears stale persisted geometry. The layout behavior test covers active, blank, missing, switch-back, and baseline-restore states; the keyless browser e2e drives the shipped composition from an active Session through New Session and back while checking the full grid track and browser errors. +Details is open by default, including when the first Session materializes. Switching to a different Session forgets the dragged details width because close writes zero and reopen uses the contract default. Unselected states derive a zero rendered track while leaving the preferred geometry unchanged; returning to the same Session through one of those states restores its width. Reload forgets sidebar and details geometry. The layout behavior test covers initial defaults, first materialization, direct and hero-mediated Session switches, same-Session return, and the absence of layout storage; the keyless browser e2e drives the same owner transitions through the shipped composition while checking the full grid track and browser errors. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md index 7570f4ad04..09452ba80f 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-web-details-session-lifecycle.zh.md @@ -6,24 +6,24 @@ Status: implemented ## 问题 -详情入口由会话作用域拥有,而其网格宽度由根作用域拥有并持久化。切换当前会话时,系统会替换或移除详情内容,却不会关闭根布局中的该列。因此,New Session 可能在空白详情面板旁显示 composer,而该面板仍占用 360 像素。普通会话切换,以及会话消失后选中状态失效,同样存在这一所有权缺口。 +详情入口由会话作用域拥有,而其首选网格宽度由根作用域拥有。选择不同会话时,系统会替换详情内容,却不会关闭根作用域的该首选宽度,因此新 owner 会继承陈旧的查看几何信息。hero 和其他未选中状态不会渲染会话作用域的详情;其轨道需派生为零宽度,但不能因此在比较中成为伪 owner。 ## 决策 -会话基线就绪后,`AppFrame` 会从权威会话投影派生唯一的详情 owner:当前会话必须仍然存在,且不得为 blank。首次就绪的活动会话属于基线恢复,因此浏览器刷新后可以保留已打开的详情宽度。若首次就绪时处于 New Session,则不存在详情 owner,系统会关闭陈旧的持久化状态。 +`AppFrame` 从权威会话投影读取当前会话 id 及其摘要中的 `blank` 标志。它只在该会话能够拥有详情时记录最后一个选中的非 blank 会话 id,因此 hero 和其他未选中状态既不会触发关闭,也不会替换最后一个会话 owner;这些状态下,详情栏轨道的渲染宽度派生为零,但存储的首选宽度不变。首个会话保留详情栏的默认宽度;返回同一会话时恢复其当前宽度;选择不同会话时,系统会先通过布局 store 关闭根作用域存储的详情栏首选宽度,再进行绘制。逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 -基线恢复后,详情 owner 每次变化都会先通过布局 store 关闭面板,再进行绘制。这涵盖活动会话之间的导航、从活动会话进入 blank New Session、清除当前选中项,以及删除后选中状态失效。返回先前的会话后,详情仍保持关闭,因为根 store 已记录这次关闭;逐会话的聊天选中项继续由 [slot 体系标准](../architecture/2026-07-22-slot-type-chain-implementation.md)所述的会话作用域 store 拥有。 - -在同一个未变化的活动会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 既不改变侧边栏操作,也不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 +布局 store 是瞬时状态,详情栏以默认宽度启动。它既不读取也不写入 `localStorage`,因此重新加载会重置两个面板的宽度,无需会话基线例外。在同一个未变化的会话内手动关闭和重新打开详情栏,仍保持原有行为。该生命周期 effect 不改变 [Workspace 拥有的 New Session 动线](../feature/2026-07-25-workspace-ui-product-flow.md)、composer 草稿、会话导航或让步链缩放。 ## 考虑过的替代方案 -**在 New Session 点击处理器中关闭详情栏。** 之所以否决:顶层 New Session、Workspace 行操作、Workspace picker、普通会话行和移除操作均可改变 owner。入口级补丁会使共享生命周期继续保持不一致。 +**在 New Session 点击处理器中关闭详情栏。** 之所以否决:未选中表面没有会话作用域的详情,不得修改几何信息。详情栏是否关闭,应由随后对两个已定义会话 owner 的比较决定。 **按会话持久化面板几何信息。** 之所以否决:产品契约需要移除陈旧上下文,而不是新增一张保存各宽度的映射。按会话保存几何信息还会在用户返回时重新打开详情栏,与选定的离开即关闭行为相悖。 -**仅在当前没有会话时隐藏详情组件。** 之所以否决:blank 会话仍是当前会话;只移除内容而不将网格轨道归零,正是本次报告的缺陷。 +**在会话基线就绪后保留持久化布局。** 之所以否决:这会仅为验证陈旧的查看状态,在呈现组件中重复实现启动生命周期。瞬时默认值无需就绪标志即可使重新加载具有确定性。 + +**将当前投影的每次变化都视为会话切换。** 之所以否决:启动时的物化、hero、清除选中项和选中状态失效都不是两个会话 owner 之间的过渡。 ## 后果 -离开活动会话会忘记拖动后的详情宽度,因为现有关闭操作会写入零值,重新打开时则使用契约默认值。刷新活动会话会保留已打开的面板,而刷新 New Session 会清除陈旧的持久化几何信息。布局行为测试覆盖 active、blank、missing、切回和基线恢复状态;无密钥浏览器 e2e 则驱动已交付的组合从活动会话进入 New Session 再返回,同时检查完整网格轨道和浏览器错误。 +详情栏默认打开,首次会话物化时亦然。切换到不同会话会忘记拖动后的详情宽度,因为关闭操作会写入零值,重新打开时则使用契约默认值。未选中状态会将轨道的渲染宽度派生为零,同时保持首选几何信息不变;经由这些状态返回同一会话时,会恢复其宽度。重新加载会忘记侧边栏与详情栏的几何信息。布局行为测试覆盖初始默认值、首次物化、直接及经 hero 中转的会话切换、返回同一会话,以及不存在布局存储的情况;无密钥浏览器 e2e 则通过已交付的组合驱动相同的 owner 过渡,同时检查完整网格轨道和浏览器错误。 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 43ca03dc7a..0283559c9c 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: d9e0a9660ecd6aeb75e835e68f92c0a268423872 -2026-07-24-web-gui-browser-e2e-lane.zh.md: e8c7d1c4596f20d88bd08423549fb6a9f7b0654b +2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154 +2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 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 d9e0a9660e..ce59dcce27 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 @@ -42,7 +42,7 @@ The typecheck plane split is structural: the host scaffold, its support module, ### Coverage contract -The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout persistence, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. +The lane covers three behavior families. Live-turn scenarios pin ordinary tool execution, cancellation, non-retryable failure, transient retry, resident questions, and mid-turn steering; synchronization uses durable events, `whenIdle()`, or an explicit replay marker rather than delays. Cold-history scenarios seed through the real persistence API and cover history rendering, sidebar search, trajectory and waterfall views, and tool details without model calls. Browser-lifecycle scenarios cover first-send workspace materialization, reload recovery, layout reset, theme and locale preferences, and workspace create/rename/view operations. Each family asserts the browser surface and the authoritative host state; a stray model call or under-consumed fixture fails teardown. ### CI stance 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 e8c7d1c459..bad3dd15ed 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 @@ -42,7 +42,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ### 覆盖契约 -该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局持久化、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 +该车道覆盖三类行为。实时轮次场景钉住普通工具执行、取消、不可重试失败、瞬态重试、常驻提问与轮次中途 steering;同步依赖持久事件、`whenIdle()` 或显式回放标记,而不使用延时。冷历史场景通过真实持久化 API 播种,在不调用模型的情况下覆盖历史渲染、侧栏搜索、Trajectory 与 Waterfall 视图及工具详情。浏览器生命周期场景覆盖首次发送时物化工作区、重新加载恢复、布局重置、主题与语言偏好,以及工作区的创建、重命名和视图操作。每类场景都断言浏览器表面和权威的 host 状态;离群的模型调用或未耗尽的 fixture 会使拆卸失败。 ### CI 立场 diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index c066077edf..c4d6483245 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -1,30 +1,33 @@ // Keyless browser regression for the details column's Session ownership. -// The real shipped composition owns the state transition: an active Session -// rehydrates an open panel, New Session replaces the details owner, and the -// root layout must release the third grid track before the next paint. +// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - acknowledgeReloadConnectionLoss, fixtureUserPrompts, launchWebScaffold, watchConsole, - webSnapshotMode, type WebScaffold, + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url)) +const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() /** Last AppFrame grid track in CSS pixels. */ async function detailsTrack(page: Page): Promise { - return await page.locator('[class*="frame"]').first().evaluate((element) => { + return await appFrame(page).evaluate((element) => { const tracks = getComputedStyle(element).gridTemplateColumns.split(' ') return Number.parseFloat(tracks.at(-1) ?? 'NaN') }) } +/** AppFrame is the only product element with an inline grid track template. */ +function appFrame(page: Page) { + return page.locator('[style*="grid-template-columns"]').first() +} + describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => { let scaffold: WebScaffold let browser: Browser @@ -32,13 +35,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S let tripwire: ReturnType beforeAll(async () => { - expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 }) + await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed') browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await appFrame(page).waitFor({ timeout: 30_000 }) await connectFreshWorkspace(page) }, 120_000) @@ -47,7 +52,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await scaffold?.close() }) - it('removes the details track for New Session and keeps it closed when returning', async () => { + it('retains geometry through hero and closes it for a different Session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle')) const settled = scaffold.whenTurnSettled() const input = page.locator('textarea').first() @@ -56,28 +61,33 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - // Rehydrate the production layout action's persisted result. The active - // Session survives reload, so its details panel remains valid and open. - await page.evaluate(() => { - localStorage.setItem('dsh.layout.panels', JSON.stringify({ sidebar: 280, details: 360 })) - }) - const warningStart = tripwire.warnings.length - await page.reload({ waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - acknowledgeReloadConnectionLoss(tripwire, warningStart) - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) - expect(await detailsTrack(page)).toBe(360) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360) expect(await page.getByText('详情', { exact: true }).count()).toBe(1) await page.getByRole('button', { name: 'New session', exact: true }).last().click() await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 }) - expect(await page.locator('[class*="frame"]').first().getAttribute('data-details-collapsed')).not.toBeNull() await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false) const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first() await original.click() await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360) + expect(await page.getByText('详情', { exact: true }).count()).toBe(1) + + const ungrouped = page.getByText('Ungrouped', { exact: true }) + const ungroupedRow = ungrouped.locator('..').locator('..') + const ungroupedSection = ungroupedRow.locator('..') + await expect.poll(async () => { + if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { + await ungrouped.click() + await page.waitForTimeout(50) + } + return await ungroupedRow.getAttribute('aria-expanded') + }, { timeout: 5_000 }).toBe('true') + const seeded = ungroupedSection.locator('[role="treeitem"]').nth(1) + await seeded.click() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 4b54242495..a07db275fd 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -101,23 +101,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('recovers the whole surface across a reload from the log alone', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-reload')) - // Fold a layout preference into the same reload: collapse the sidebar - // (persisted under dsh.layout.panels) before reloading. - await page.getByRole('button', { name: 'Collapse sidebar' }).click() - await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) - // Layout persisted: the sidebar comes back collapsed. - await expect.poll(() => page.getByRole('button', { name: 'Open sidebar' }).count(), { timeout: 10_000 }).toBe(1) // Selection persisted (dsh.sessions.current) and history replayed: the // recorded turn re-renders from session.history with zero model calls — // the replay cursor was fully consumed before the reload, so any stray // request would fail the scenario loudly at close(). await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // Expand back and confirm the tree still lists the materialized session. - await page.getByRole('button', { name: 'Open sidebar' }).click() await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) // Golden of the recovered conversation region: rebuilt from the log, it // must render the same settled transcript the live turn produced. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 7b7721ce2c..42b73c1a31 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -469,7 +469,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '09-details-closed') }, 150_000) - it('6 sidebar drag widens the column and persists across reload', async () => { + it('6 sidebar drag widens the column and resets across reload', async () => { onTestFailed(() => saveFailureShot(page, 'w5-drag')) const before = await firstTrack(page) const handle = page.locator('[class*="handle"]').first() @@ -484,7 +484,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke await screen(page, '10-sidebar-dragged') await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - expect(await firstTrack(page)).toBe(after) + expect(await firstTrack(page)).toBe(before) }) it('7 dark mode: the body attribute cascades the token sheets', async () => { diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index ef1d66f060..eff1fbe925 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/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-layout/README.md -README.md: 836100066039e3695e314a4a4bbfaba8fb20c652 -README.zh.md: ffef7511b6cfdc3109203be86199766073bf5efd +README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4 +README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 8361000660..9354f4b79f 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). -AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The first ready active Session may restore an open details width across reload; New Session and every later current-Session change close details before paint, including selection invalidation after deletion. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. +AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces. The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`. @@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Details width is global, not retained per Session** — changing or losing its active Session closes the panel and forgets a dragged width; returning to that Session does not reopen it. -- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth. +- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry. +- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth. - **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index ffef7511b6..c949236557 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -4,7 +4,7 @@ 外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 -AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。首次就绪的活动会话可在重新加载后恢复已打开的详情宽度;New Session 以及后续每次当前会话变化,都会在绘制前关闭详情栏,包括删除后选中状态失效的情况。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 +AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零,但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id:首个会话以默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed` 和 `width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。 `/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。 @@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态, ## 已知限制与暂缓事项 -- **详情宽度是全局状态,不按会话保留**:切换或失去当前活动会话会关闭详情栏,并忘记拖动后的宽度;返回该会话时不会重新打开详情栏。 -- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。 +- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。 +- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。 - **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。 diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index da7636de9c..8aa16d8675 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -91,33 +91,21 @@ export function AppFrame({ renderSlot, }: AppFrameProps) { const panels = useStore(s => s) - const sessionsPhase = useSessions(s => s.phase) const detailsSession = useSessions((s) => { const current = s.current - if (current === undefined) return undefined - const session = s.byId[current] - return session !== undefined && !session.blank ? current : undefined + return current !== undefined && s.byId[current]?.blank === false ? current : undefined }) const frameRef = useRef(null) const [viewport, setViewport] = useState(() => window.innerWidth) - // The first ready active Session is baseline restoration, so its persisted - // panel may remain open. New Session has no inspectable selection, and any - // later details owner change closes the root-scoped column before paint. - const detailsBaselineReady = useRef(false) - const previousDetailsSession = useRef(detailsSession) + const lastSession = useRef(detailsSession) useLayoutEffect(() => { - if (sessionsPhase !== 'ready') return - if (!detailsBaselineReady.current) { - detailsBaselineReady.current = true - previousDetailsSession.current = detailsSession - if (detailsSession === undefined) actions.closeDetails() - return + if (detailsSession === undefined) return + if (lastSession.current !== undefined && lastSession.current !== detailsSession) { + actions.closeDetails() } - if (previousDetailsSession.current === detailsSession) return - previousDetailsSession.current = detailsSession - actions.closeDetails() - }, [actions, detailsSession, sessionsPhase]) + lastSession.current = detailsSession + }, [actions, detailsSession]) // Track the frame's own box (not the window): rAF-throttled ResizeObserver. useEffect(() => { @@ -139,12 +127,12 @@ export function AppFrame({ } }, []) - const cols = computeColumns(viewport, panels.sidebar, panels.details) + const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details) const colsRef = useRef(cols) colsRef.current = cols // The drag base is the rendered width captured at drag start (grabbing a - // concession-clamped panel must not jump back to the persisted preference); + // concession-clamped panel must not jump back to the stored preference); // it stays frozen for the whole gesture so dx deltas do not compound. const sidebarBase = useRef(0) const detailsBase = useRef(0) diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index 7cd5f8c2d8..125bb92a70 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -1,7 +1,7 @@ /** * Pure concession-chain column solver for the three-column AppFrame. * Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking - * details, then auto-closing it (derived zero width — persisted width + * details, then auto-closing it (derived zero width — preferred width * preferences are never rewritten, so widening the window restores them). * The sidebar never concedes: its rendered width is always the drag * preference (or the collapsed rail), and center absorbs any remaining @@ -45,8 +45,8 @@ export function clampWidth(px: number, min: number, max: number): number { /** * Solve the three column widths for one viewport frame. Pure: no hysteresis — * the output is a function of (viewport, preferences) only, so recovery on - * re-widening is automatic. Preferences re-clamp here because they cross a - * durable boundary (localStorage rehydration may carry stale ranges). + * re-widening is automatic. Preferences re-clamp here because they cross the + * store boundary and callers may still supply stale ranges. * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). diff --git a/packages/client/ui-layout/src/client/stores.ts b/packages/client/ui-layout/src/client/stores.ts index 06bcbe5ae3..01115c12a5 100644 --- a/packages/client/ui-layout/src/client/stores.ts +++ b/packages/client/ui-layout/src/client/stores.ts @@ -1,7 +1,7 @@ /** - * The root entry's layout store: panel geometry as plain widths in px - * (0 = closed), persisted across reloads. Module level exports the factory - * only — a module-level handle would pin the store's identity in the module + * The root entry's transient layout store: panel geometry as plain widths in + * px (0 = closed). Module level exports the factory only — a module-level + * handle would pin the store's identity in the module * cache (a de-facto singleton surviving plugin reloads). register() receives * the factory (exclusive use: the framework instantiates per entry), AppFrame * derives its PropsStore share from the return type, and the service face @@ -29,17 +29,16 @@ type LayoutActions = { } /** - * Create the layout panel store handle. The persisted preference IS the - * width, so closing a panel forgets its drag width — reopening restores the - * contract default. Actions are the complete write set: drag writes clamp + * Create the layout panel store handle. The preference IS the width, so + * closing a panel forgets its drag width — reopening restores the contract + * default. Actions are the complete write set: drag writes clamp * into the panel's contract range and never cross the open/closed line; * open/close transitions write 0 / the default explicitly. * @returns the store handle (spec + type + identity + factory in one). */ export function createLayoutStore(): EngineStoreHandle { const handle = defineStore({ - init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }), - persist: 'dsh.layout.panels', + init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }), actions: { setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) }, setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) }, diff --git a/packages/client/ui-layout/src/invariant.ts b/packages/client/ui-layout/src/invariant.ts index fa46392b5d..dd572e679d 100644 --- a/packages/client/ui-layout/src/invariant.ts +++ b/packages/client/ui-layout/src/invariant.ts @@ -15,8 +15,8 @@ export const name = 'client-ui-layout-invariant' export const inject = ['invariants'] /** - * No runtime invariant: shell viewing-state stores (zustand+persist) behind - * ctx.layout — it emits no cordis events; clamp/prune/concession-chain + * No runtime invariant: the shell viewing-state store behind ctx.layout emits + * no cordis events; clamp/prune/concession-chain * sequencing is asserted directly by this package's columns and service specs. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 54c23688dc..95933b2783 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -24,7 +24,6 @@ import type { // Session selection controls for the SessionProvider and useSessions stubs. const selectedSession = { current: 's-test' as SessionId | undefined } const selectedSessionBlank = { current: false } -const sessionsPhase = { current: 'ready' as SessionListState['phase'] } const baselinesReady = { current: true } // Render-prop contract stub fed through the standard seat prop (the renderer @@ -56,7 +55,6 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho function mountFrame() { window.innerWidth = frameWidth // first-render viewport source before the observer fires const instance = createLayoutStore().create() - instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360 const slotCalls: { key: string; props: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, props: owner }) @@ -74,7 +72,7 @@ function mountFrame() { ? {} : { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } }, current, - phase: sessionsPhase.current, + phase: 'ready', } as SessionListState return sel(sessionState) }) as never @@ -116,9 +114,7 @@ beforeEach(() => { frameWidth = 1920 selectedSession.current = 's-test' as SessionId selectedSessionBlank.current = false - sessionsPhase.current = 'ready' baselinesReady.current = true - localStorage.clear() // the layout store persists; instances must not bleed across tests vi.useFakeTimers() vi.stubGlobal('ResizeObserver', ResizeObserverStub) vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number) @@ -176,7 +172,7 @@ describe('AppFrame', () => { expect(slotCalls.map(c => c.key)).toContain('details') }) - it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => { + it('ignores unselected states and closes only when the Session id changes', () => { const { frame, instance, rerenderFrame } = mountFrame() expect(tracks(frame)).toEqual([280, 360]) @@ -189,31 +185,30 @@ describe('AppFrame', () => { selectedSessionBlank.current = true act(() => { rerenderFrame() }) expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().details).toBe(360) - selectedSession.current = 's-test' as SessionId + selectedSession.current = 's-next' as SessionId selectedSessionBlank.current = false act(() => { rerenderFrame() }) - expect(tracks(frame)).toEqual([280, 0]) + expect(tracks(frame)).toEqual([280, 360]) - act(() => { instance.actions.openDetails() }) selectedSession.current = undefined act(() => { rerenderFrame() }) expect(tracks(frame)).toEqual([280, 0]) + selectedSession.current = 's-test' as SessionId + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 0]) }) - it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => { - sessionsPhase.current = 'pending' - const active = mountFrame() - expect(tracks(active.frame)).toEqual([280, 360]) - sessionsPhase.current = 'ready' - act(() => { active.rerenderFrame() }) - expect(tracks(active.frame)).toEqual([280, 360]) - active.unmount() + it('keeps the default details width when the first Session materializes', () => { + selectedSession.current = undefined + const { frame, instance, rerenderFrame } = mountFrame() + expect(tracks(frame)).toEqual([280, 0]) + expect(instance.getSnapshot().details).toBe(360) - selectedSession.current = 's-blank' as SessionId - selectedSessionBlank.current = true - const blank = mountFrame() - expect(tracks(blank.frame)).toEqual([280, 0]) + selectedSession.current = 's-first' as SessionId + act(() => { rerenderFrame() }) + expect(tracks(frame)).toEqual([280, 360]) }) it('sidebar slot receives live concession output as owner props', () => { diff --git a/packages/client/ui-layout/tests/layout-store.spec.ts b/packages/client/ui-layout/tests/layout-store.spec.ts index e5938d0a1b..3ec3cb2c7e 100644 --- a/packages/client/ui-layout/tests/layout-store.spec.ts +++ b/packages/client/ui-layout/tests/layout-store.spec.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom /** * createLayoutStore unit account: init shape, the action write set (clamp - * inside actions), and the persist key round-trip over jsdom localStorage. - * Uses the test-sanctioned path: factory self-call + .create() gives the + * inside actions), and the absence of browser persistence. Uses the + * test-sanctioned path: factory self-call + .create() gives the * real engine instance (same create path as production). */ import { beforeEach, describe, expect, it } from 'vitest' @@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels' beforeEach(() => { localStorage.clear() }) describe('createLayoutStore', () => { - it('initializes with sidebar open at default and details closed', () => { + it('initializes both panels at their default widths', () => { const { store } = createLayoutStore().create() - expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 }) + expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }) }) it('each create() is an independent instance (factory is not a singleton)', () => { @@ -52,6 +52,7 @@ describe('createLayoutStore', () => { it('openDetails is a no-op when already open; closeDetails zeroes', () => { const { store, actions } = createLayoutStore().create() + actions.closeDetails() actions.openDetails() expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT) actions.setDetails(500) @@ -61,13 +62,16 @@ describe('createLayoutStore', () => { expect(store.getSnapshot().details).toBe(0) }) - it('persists under dsh.layout.panels and rehydrates on the next create', () => { + it('does not persist panel geometry', () => { const first = createLayoutStore().create() - first.actions.setSidebar(320) - first.actions.openDetails() - expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT }) + first.actions.setSidebar(400) + first.actions.closeDetails() + expect(localStorage.getItem(PERSIST_KEY)).toBeNull() const second = createLayoutStore().create() - expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT }) + expect(second.store.getSnapshot()).toEqual({ + sidebar: SIDEBAR_DEFAULT, + details: DETAILS_DEFAULT, + }) }) }) From c8ea9a5204e9f8371d73e32fbda0ff957b6340bc Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 29 Jul 2026 15:11:06 +0800 Subject: [PATCH 128/153] fix(ui-conversation): share MessageIconActions to clear jscpd clone User and assistant chrome both rendered copy/branch buttons; one shared row owns the chrome and keeps clock placement / edit as props. --- .../client/chat/AssistantMarkdown.module.css | 42 +----------- .../src/client/chat/AssistantMarkdown.tsx | 40 ++++-------- .../client/chat/MessageIconActions.module.css | 53 +++++++++++++++ .../src/client/chat/MessageIconActions.tsx | 65 +++++++++++++++++++ .../src/client/chat/MessageItem.module.css | 43 +----------- .../src/client/chat/MessageItem.tsx | 46 +++---------- 6 files changed, 142 insertions(+), 147 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 24bc4c0e49..d988cf52f8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -28,55 +28,17 @@ line-height: 18px; } -/* Finalized footer: copy / branch / clock (figma 43:32997). */ +/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */ .actions { - display: flex; - align-items: center; - gap: 10px; - height: 28px; margin-top: 16px; /* Optical align with 28px icon hit targets that pad 6px past the glyph. */ margin-left: -6px; } -/* Clock after the icon buttons; pl 12 separates it from branch. */ -.time { - padding-left: 12px; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); - white-space: nowrap; -} - -/* Hover-capable pointers: hide until the root is hovered/focused. Touch / - hover:none keeps actions visible (opacity:0 still hit-tests). */ +/* Hover-capable pointers: reveal shared actions on root hover/focus. */ @media (hover: hover) { - .actions { - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); - } - .root:hover .actions, .root:focus-within .actions { opacity: 1; } } - -.action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 6px; - border: none; - border-radius: 28px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; -} - -.action:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); -} diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index a3a05b7af9..e5a89a9e88 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -6,14 +6,12 @@ // the turn-level loading dots live in the chat view's tail, not here. // Finalized nodes append IconActions (copy / branch / clock) once streaming ends. -import { memo, useCallback } from 'react' +import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { - IconBranchOutline16, IconCopyOutline16, IconThinkOutline14, - JsonBlock, MarkdownText, Tooltip, + IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import { formatMessageClock, writeClipboard } from './message-chrome.ts' -import { useCalendarDay } from './use-calendar-day.ts' +import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -55,29 +53,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { ) } -/** Finalized assistant IconActions (figma 43:32997): copy live; branch stub; clock. */ -function AssistantActions({ text, time }: { text: string; time: number }) { - const day = useCalendarDay() - const onCopy = useCallback(() => { - void writeClipboard(text) - }, [text]) - return ( -
- - - - - - - {formatMessageClock(time, day)} -
- ) -} - export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, time, }: AssistantMarkdownProps) { @@ -105,7 +80,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && 已停止} - {showActions && } + {showActions && ( + + )} ) }) diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css new file mode 100644 index 0000000000..30d6920609 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.module.css @@ -0,0 +1,53 @@ +/* Shared message IconActions row (user + assistant). Parent modules own + hover-reveal selectors and layout offsets via the composed className. */ + +.actions { + display: flex; + align-items: center; + gap: 10px; + height: 28px; +} + +/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */ +.timeStart { + padding-right: 12px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +.timeEnd { + padding-left: 12px; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} + +/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */ +@media (hover: hover) { + .actions { + opacity: 0; + transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); + } +} + +.action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + border: none; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx new file mode 100644 index 0000000000..7579a4c249 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -0,0 +1,65 @@ +// Shared IconActions chrome for user and assistant messages: copy / branch +// live (branch still a stub), date-aware clock, optional edit stub. + +import { useCallback } from 'react' +import { + IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { formatMessageClock, writeClipboard } from './message-chrome.ts' +import { useCalendarDay } from './use-calendar-day.ts' +import css from './MessageIconActions.module.css' + +export interface MessageIconActionsProps { + /** Plain text the copy action writes. */ + text: string + /** Unix epoch ms for the clock label. */ + time: number + /** Clock before icons (user) or after (assistant). */ + clock: 'start' | 'end' + /** When true, append the stub edit control (user bubble). */ + edit?: boolean | undefined + /** Parent layout / hover-reveal class composed onto the actions row. */ + className?: string | undefined +} + +/** + * Copy / branch (/ clock) IconActions row shared by user and assistant chrome. + * @param props - Copy text, event time, clock side, optional edit, className. + * @returns The actions row element. + */ +export function MessageIconActions({ + text, time, clock, edit, className, +}: MessageIconActionsProps) { + const day = useCalendarDay() + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + const clockEl = ( + + {formatMessageClock(time, day)} + + ) + return ( +
+ {clock === 'start' ? clockEl : null} + + + + + + + {edit === true && ( + + + + )} + {clock === 'end' ? clockEl : null} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index a2c14fdc7a..260382d530 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -20,55 +20,14 @@ color: var(--dsw-alias-label-primary); } -.actions { - display: flex; - align-items: center; - gap: 10px; - height: 28px; -} - -/* Clock before the icon buttons (figma 388:20051); pr 12 separates it from copy. */ -.time { - padding-right: 12px; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); - white-space: nowrap; -} - -/* Hover-capable pointers: hide until the row is hovered/focused. Touch / - hover:none keeps actions visible (opacity:0 still hit-tests). */ +/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */ @media (hover: hover) { - .actions { - opacity: 0; - transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out); - } - .userRow:hover .actions, .userRow:focus-within .actions { opacity: 1; } } -.action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 6px; - border: none; - border-radius: 28px; - background: transparent; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; -} - -.action:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); -} - .badge { display: inline-block; margin-bottom: 4px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index acd2a7023f..a149d37337 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -4,17 +4,13 @@ // the snapshot cache; memo holds across streaming because unchanged nodes // keep their references. -import { memo, useCallback } from 'react' +import { memo } from 'react' import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { - IconBranchOutline16, IconCopyOutline16, IconEditOutline16, - JsonBlock, MessageText, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' -import { formatMessageClock, writeClipboard } from './message-chrome.ts' -import { useCalendarDay } from './use-calendar-day.ts' +import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' export interface MessageItemProps { @@ -64,34 +60,6 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -/** User-bubble IconActions (figma 388:20051): clock + copy live; branch/edit stubs. */ -function UserActions({ text, time }: { text: string; time: number }) { - const day = useCalendarDay() - const onCopy = useCallback(() => { - void writeClipboard(text) - }, [text]) - return ( -
- {formatMessageClock(time, day)} - - - - - - - - - -
- ) -} - export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { case 'user': { @@ -102,7 +70,13 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {projectUserText(text)} {rest.map((block, i) => )} - + ) } From 46e1e86c764d0ceaffe75fc60957e3a11fce023d Mon Sep 17 00:00:00 2001 From: j-xiang Date: Wed, 29 Jul 2026 15:29:03 +0800 Subject: [PATCH 129/153] docs(i18n): bind reviewed README terminology --- docs/i18n/terminology.md | 14 +++++++++----- .../request-response.expected.json | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 3a40629372..bf2590931a 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -37,6 +37,7 @@ | agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 | | agent loop | agent loop | agent loop(智能体循环) | | | | blob hash | blob hash | | | `git hash-object` 的结果 | +| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 | | Cordis | Cordis | | | | | dispose | dispose | dispose(资源释放) | | | | doc-sync | doc-sync | doc-sync(文档同步门禁) | | | @@ -54,7 +55,7 @@ | Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 | | schema | schema | | | | | schema DSL | schema DSL | | | | -| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | +| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` | | skill | skill | skill(技能) | | | | spawn | spawn | | | | | steering | steering | steering(中途引导) | | | @@ -74,21 +75,23 @@ | adapter | 适配器 | | | | | adapter contract | 适配器契约 | 适配器契约(adapter contract) | | | | append-only | 仅追加 | | | | -| artifact | 产物 | | | | +| artifact | 产物 | | 制品 | | | backend | 后端 | | | | | background task | 后台任务 | | | | | block | 块 | | | | | build target | 构建目标 | | | | | cancel | 取消 | | | | +| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` | | feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 | | feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 | | checkpoint | 检查点 | | | | | chunk | 分片 | | | | | compaction | 压缩 | 压缩(compaction) | | | | companion tool | 配套工具 | | | | +| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` | | Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 | | config key | 配置键 | | | Cordis 插件配置中的单个字段 | -| consumer | 消费方 | | | | +| consumer | 消费方 | | 消费者 | | | content block | 内容块 | | | | | Cookbook | 实操手册 | | | 文档标题用语 | | context | 上下文 | | | | @@ -145,9 +148,10 @@ | persistence | 持久化 | | | | | pipeline | 流水线 | | | | | plugin | 插件 | | | | +| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 | | prompt | 提示词 | | | | | provider | 提供方 | | | | -| provider-neutral | 提供方无关 | | | | +| provider-neutral | 提供方无关 | | 提供方中立 | | | quality gate | 质量门禁 | | | | | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | | reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 | @@ -165,7 +169,7 @@ | sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 | | smoke test | 冒烟测试 | | | | | snapshot | 快照 | | | | -| source of truth | 真源 | | | | +| source of truth | 真源 | | 事实来源、唯一来源 | | | spine | 主干 | | | | | staged | 暂存 | | | 沿用 git 官方中文翻译 | | stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` | diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 96d2dc805f..428d3d49a4 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(Complete translation of the source document)\n\n\n\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n\n\n\n(Final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write \"无修正\" in `` and copy the translation unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the given source document from English to Chinese, producing natural, professional technical prose.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains exactly the same structure as the source: heading hierarchy, list shape, table columns, link targets, and code blocks.\n- Fenced code blocks must be byte-identical to the source, including ALL comments inside them. Do NOT translate comments inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans (commands, flags, paths, API names, version numbers) must be kept verbatim. Never translate or reformat them.\n- Every relative link must point to the same target as in the source. Link text is translated; link targets are not.\n- Language switcher line: when translating into Chinese, write `[English](source-filename.md) | 中文`. When translating into English, write `English | [中文](source-filename.zh.md)`. Do NOT copy the switcher line from the source file unchanged — you must flip the link direction.\n- After a closing bold marker `**`, insert a space before the next character when that character is a Latin letter, digit, or CJK ideograph. Never insert a space before any punctuation (full-width or half-width).\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native speaker. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Use polite imperative forms where the text instructs the reader to do something.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences with commas or semicolons. Avoid run-on sentences.\n- Prefer active voice. Convert passive constructions to active if it reads more naturally.\n- Translate meaning, not words. Restructure sentences where the target language grammar requires it.\n- Do not invent words or expressions that do not exist in natural technical writing of the target language.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate two different source-language terms that carry distinct meanings.\n- Avoid repeating the same verb in close proximity; vary word choice for readability.\n\n#### When translating into Chinese\n- When a number modifies a noun, always include a Chinese classifier or measure word (量词). For example: \"three-package seam\" → \"由三个包构成的 seam\", not \"三包 seam\".\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in prose: `,。:;?!()「」`.\n- Strongly prefer replacing all em-dashes (——) with colons, periods, commas, or parentheses. Keep an em-dash only if no other punctuation works at all.\n- Use enumeration commas (、) between parallel items, not regular commas.\n- List item endings: use semicolons or no punctuation. Do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words/numbers.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以) and keep the SOURCE emphasis marker: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n(To be added.)\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On first occurrence, write the \"首次出现\" value with its parenthetical gloss; on subsequent occurrences, write only the part before the parentheses.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- For technical terms not in the table, follow the target language: for a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source, or keep the source term and flag it as pending when no such precedent exists; for an English target, use the established English technical term, or preserve an ambiguous source term with a short English gloss and flag it as pending. Do not invent a translation. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | Agent Note(agent 决策记录) | 智能体注记、智能体笔记 | 本仓库中由 agent 撰写的提案与决策记录 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库接口、实现与消费方分离的命名架构概念;普通 `seam` 仍按其词条处理 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 与 `extension point` 是不同概念;根据具体语境,可译为`服务边界`或`可替换点` |\n| skill | skill | skill(技能) | | |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| task id | task id | | 任务 id | 保留英文 |\n| subagent | subagent | | | |\n| thinking | thinking | | | API 字段保留英文;描述模型模式时译为`思考` |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器契约 | 适配器契约(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| background task | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 契约 | | | 如:`pairing contract` →`配对契约` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| coverage | 覆盖率 | | | |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| ergonomics | 易用性 / 开发体验 | | 人体工学 | API 或面向模型的接口用「易用性」;工具链或开发者工作流用「开发体验」 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash |\n| finish reason | 结束原因 | | | |\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| memory | 记忆 / 内存 | | | 与 `agent` 搭配时译为`记忆`(如 `agent memory` →`智能体记忆`);指系统资源时译为`内存` |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供方 | | | |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | 包(package) | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving surface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| staged | 暂存 | | | 沿用 git 官方中文翻译 |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| streaming | 流式输出 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nProduce your output in three XML sections:\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(Complete translation of the source document)\n\n\n\n(Self-review notes, one correction per line with category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- 无修正\n\n\n\n(Final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, re-read it in the target language only, without looking at the source. Check by category:\n\n**Structure**\n- Is the heading hierarchy, list shape, and code block content identical to the source?\n- Are ALL comments inside code blocks left untranslated (byte-identical to source)?\n- Is the language switcher line correctly flipped (not copied from source)?\n- Are link targets preserved, and are spaces after bold markers present only before Latin letters, digits, or CJK ideographs?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native speaker?\n- Is there any colloquial, casual, or overly informal phrasing?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that should be converted to active voice?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Is the same target-language word used to translate two distinct source concepts?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied (not missing, not repeated)? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- For unlisted terms, does a Chinese target use established Chinese precedent or retain the source term as pending, and does an English target use established English terminology or preserve only an ambiguous source term with a short English gloss?\n\n**Punctuation** (when target is Chinese)\n- Are there em-dashes that should be replaced with colons, periods, or commas?\n- Are list items ending with commas instead of semicolons?\n- Do RFC 2119 keywords preserve the source emphasis exactly?\n\nRecord corrections in `` with category tags. Then output the corrected version in ``. If no corrections are needed, write \"无修正\" in `` and copy the translation unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to hear without the source anchoring you`\n- Bad: `没有源文锚着,别扭的表述更容易被听出来`\n- Good: `不对照原文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From 333b4bcd30fd211fdfbc657853fc4cda10f593c8 Mon Sep 17 00:00:00 2001 From: j-xiang Date: Wed, 29 Jul 2026 15:29:24 +0800 Subject: [PATCH 130/153] docs(i18n): proofread README translations 1-20 --- .agents/notes/README.zh.md | 14 ++++---- docs/postmortem/README.zh.md | 6 ++-- examples/README.zh.md | 14 ++++---- examples/acp-agent/README.zh.md | 12 +++---- examples/cordis-agent/README.zh.md | 8 ++--- examples/headless-agent/README.zh.md | 10 +++--- examples/jsonrpc-agent/README.zh.md | 8 ++--- examples/tui-agent/README.zh.md | 30 ++++++++-------- native/README.zh.md | 6 ++-- native/landlock-run/README.zh.md | 8 ++--- .../landlock-run/packages/entry/README.zh.md | 6 ++-- .../packages/linux-arm64/README.zh.md | 4 +-- .../packages/linux-x64/README.zh.md | 4 +-- packages/acp/README.zh.md | 4 +-- packages/acp/acp/README.zh.md | 36 +++++++++---------- packages/bash/README.zh.md | 4 +-- packages/bash/bash-local/README.zh.md | 14 ++++---- packages/bash/bash-sandbox/README.zh.md | 28 +++++++-------- packages/bash/bash/README.zh.md | 14 ++++---- 19 files changed, 115 insertions(+), 115 deletions(-) diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index ddecac7951..a3369a94c6 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -11,7 +11,7 @@ - **生命周期**(顶层文件夹)是 Agent Note 的状态,Agent Note 随状态变化在文件夹之间移动: - **`proposed/`**:实施前评审的提案;尚未构建(或仅部分构建)。 - **`implemented/`**:决策已交付。文件记录做了什么决定、否决了什么,并**与实际交付的内容保持同步**:当代码后续移动文件、重命名包(package)或更改键名/默认值时,Agent Note 在同一个变更中同步更新(仅限事实——路径、名称、结构——而非决策本身)。见 [implemented/AGENTS.md](implemented/AGENTS.md)。 - - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的三个配对文件。 + - **`rejected/`**:提案经过讨论后被否决。仅当其决策依据仍能避免一种诱人且影响重大的错误时保留;否则删除完整的英文、中文和伴随记录三文件组。 - **类别**(嵌套文件夹)是决策的*种类*——见下方[分类](#classification)。 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 @@ -28,7 +28,7 @@ |---|---| | `feature` | 面向用户或模型的新功能。 | | `bug-fix` | 修正缺陷或弥补事故复盘(postmortem)发现的缺口。 | -| `simplification` | 在不增加功能的前提下移除代码、行为或对外表面积。 | +| `simplification` | 在不增加功能的前提下移除代码、行为或对外范围。 | | `architecture` | 关于**交付源码**的结构性决策:包之间的关系、运行时词汇。 | | `process` | 代码**周边**的工具、策略或工作流——门禁、包管理器、vendor 化——不涉及运行时行为。 | | `testing` | 测试基础设施与策略。 | @@ -39,13 +39,13 @@ 当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 -归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随文件,并修复或删除入站链接。归档时只允许对内容做这些更改。 +归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。 ## 何时需要写一份 -每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 @@ -112,7 +112,7 @@ Status: ### 曾考虑的替代方案——必需 -每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not ?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——正是这些 Agent Note 存在的意义所要防止的。 +每份 Agent Note 都必须包含 `## Alternatives considered` 章节:每个真实的替代方案及其落选原因,每个替代方案用一个加粗引导的段落,或对争议较大的替代方案用 `### Why not ?` 子节。记录决策时不记录它击败了什么,就是在邀请反复争论——这正是 Agent Note 旨在防止的问题。 替代方案是记录下来的,不是凭空编造的。日期早于 2026-07-05 且替代方案无法从记录中重建的 Agent Note,在该章节位置放置以下精确注释,门禁仅对格式规范之前的文件接受此注释: @@ -122,8 +122,8 @@ Status: ### 在生命周期之间移动 -将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——即 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写,使之机械化。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 +将文件在生命周期文件夹之间移动意味着在同一个变更中更新 `Status:` 行并满足目标文件夹的骨架要求——否则门禁会失败。具体而言,`proposed/` → `implemented/` 将 `## Proposal` 改写为现在时态的 `## Decision`,将 `## Acceptance criteria` 和 `## Risks` 折入 `## Consequences`(或折入一个现在时态的 `## Testing`/`## Verification` 章节,用于描述现在锁定该行为的内容),并用实际交付的内容替换计划——也就是将 [implemented/AGENTS.md](implemented/AGENTS.md) 所要求的改写变成可机械检查的规则。`proposed/` → `rejected/` 仅在 `Status:` 行添加原因并冻结文件。 ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文兄弟文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 +`.zh.md` 对侧文件按 [i18n 契约](../../docs/i18n/README.md)逐章节镜像其英文对侧文件的结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件——配对门禁负责它们的一致性。 diff --git a/docs/postmortem/README.zh.md b/docs/postmortem/README.zh.md index 2ce6de475c..e364ef30e3 100644 --- a/docs/postmortem/README.zh.md +++ b/docs/postmortem/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -事故复盘:一个 bug 到达了它不该到达的地方(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 +事故复盘记录的是:一个 bug 流入了不该流入的环节(真实用户、已合并的 PR(Pull Request)、已发布的版本),值得关注的是*为什么我们的流程放过了它*,而不仅仅是那一行修复。 -事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及加了哪些具体的防护措施使同类 bug 下次能被显式暴露。 +事故复盘不是 [Agent Note(agent 决策记录)](../../.agents/notes/README.md)(Agent Note 记录一个经过深思熟虑的设计决策及其被否决的替代方案,或提出未来工作)。它是一份回顾性的失败记录:什么坏了、机制是什么、为什么每道安全网都没拦住、以及为此新增了哪些具体防护措施,以确保同类 bug 下次出现时会明确报错。 当一个 bug 满足以下条件时,请撰写事故复盘:**隐蔽**(机制不显而易见,即使是细心的工程师也得费力重新推导)、**系统性**(逃逸的原因是测试/工具/约定的缺口,而非一次性的笔误)、**重新发现的代价高**(它消耗了真实的调试时间,且下次还会如此)。请链接该事故复盘所推动建立的防护措施(测试、AGENTS.md 规则、ADR)。 -每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、持久的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 +每篇事故复盘以一段**摘要**开头:一个简短段落,让忙碌的读者在三十秒内吸收要点——什么坏了、用直白的话说根因是什么、为什么逃逸了、可长期沿用的教训是什么——然后才是后续的详细「概述 / 时间线 / 根因 / 防护措施」各节。 | # | 标题 | |---|---| diff --git a/examples/README.zh.md b/examples/README.zh.md index 72ab92602d..6f2029fe48 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,11 +2,11 @@ [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`。组合和启动粘合代码位于 [`@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。 ## 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)。 @@ -18,18 +18,18 @@ ## 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 -**自指** 演示:编码主干加 [`@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` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一临时插件 注入的服务),并再次卸载它们。这些插件 只存在于内存中,共享一个内部 `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](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +使用 `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)。 ## acp-agent -作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 +一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 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)。 -默认 `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.zh.md b/examples/acp-agent/README.zh.md index 0c5f8866ea..33dc6842b6 100644 --- a/examples/acp-agent/README.zh.md +++ b/examples/acp-agent/README.zh.md @@ -2,29 +2,29 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的自动化导向 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。它面向父 agent(智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 +通过 JSON-RPC stdio 提供的面向自动化的 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 服务器。它面向parent agent(父智能体)、subagent 提供方和其他程序化客户端,而非产品 UI。 ```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 ``` -该叶节点加载 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 服务器。 +该叶节点加载 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(测试前置数据)服务器。 ## 协议通道 -Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;叶节点的附加项必须使用 stderr 输出诊断信息。 +Stdout 只携带以换行分隔的 ACP JSON-RPC。`@deepseek-ai/dsh-acp-demo` 不安装 stdout logger;该叶节点新增的组件必须使用 stderr 输出诊断信息。 自动化契约(支持的方法、基线提示词内容、已提交文本输出,以及有意缺少的 UI 界面)位于 [`@deepseek-ai/dsh-acp`](../../packages/acp/acp/README.md)。 ## 会话 workspace 与权限 -每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 与文件系统变更会根据该会话 cwd 解析 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 +每次 `session/new` 都提供一个绝对 `cwd`。受沙箱限制的 bash 和文件系统修改会以该会话 cwd 为基准应用 `workspace-write`,因此并发会话可以使用不同的项目根目录;平台临时根目录仍是共享可写暂存空间(参见[沙箱契约](../../packages/sandbox/sandbox/README.md))。`DSH_PERMISSION_MODE` 在部署和测试中选择 `workspace-write` 或 `danger-full-access`。 -在 `workspace-write` 下,模型请求扩大沙箱权限的重试会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;解除对话框或答案不可用时会失败闭合。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 +在 `workspace-write` 下,如果模型重试请求更广泛的沙箱访问权限,就会触发 `session/request_permission`,选项为 `allow_once` 和 `reject_once`。客户端以程序方式决策;客户端放弃选择或无法给出答复时,系统会按拒绝处理。选定结果仅适用于该次重试,并通过常规工具结果/审计路径记录。服务器绝不公开权限选择器,也不持久化客户端策略。 ## 快照测试 -此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖场景包括抛出/挂起行为,可选 `workspace/` fixture(测试前置数据)则为外部状态检查预置环境。 +此示例拥有 ACP 快照套件。它会启动真实自动化服务器,通过 `dsh-llm-replay` 回放已提交的模型流,并比较规范化后的协议输出与重新持久化的会话日志。录制使用真实模型;刷新会复用已提交的回放输入。覆盖配置涵盖抛错/挂起行为,可选的 `workspace/` fixture 则为环境状态检查预置状态。 大多数场景锁定后端行为,而非 ACP 专用行为;[仅面向自动化的 ACP 决策](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)说明了为何该覆盖仍与传输层耦合。 diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index c2873b6de9..f038ce8368 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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)。 ## 运行 @@ -15,7 +15,7 @@ pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 pnpm run demo:cordis acp # ACP server ``` -预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: +预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身: ``` > Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. @@ -30,8 +30,8 @@ pnpm run demo:cordis acp # ACP server [tool call] cordis_unmount({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`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/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 68ec718afe..956bc82e77 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 +无头单次 agent(智能体)接线:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与全新 agent Ralph 迭代 + `todo_write` + JSONL 持久化,并以 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo) 作为应用入口。 ## 运行 @@ -15,12 +15,12 @@ pnpm run demo:headless --output-format json -- "summarize the implementation" pnpm run demo:headless --output-format stream-json -- "run the focused tests" ``` -必须提供且只能提供一个非空位置任务;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父工具事件和结果对外显示。 +必须提供一个且仅一个非空的任务位置参数;含空格的任务需要加引号。没有 `-p` 标志。`text` 打印最后一条包含文本的 assistant 消息,`json` 打印一条 DSH 原生结果记录,`stream-json` 则在该记录之前发出顶层会话的规范任务轮次事件。子会话只通过父会话的工具事件和结果对外显示。 -每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷新、释放并退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动 workspace、运行命令、spawn 子 agent,并消耗提供方 token。 +每次调用都会创建并持久化新会话,在一个轮次中运行所有模型和工具步骤,然后刷写持久化数据、执行 dispose(资源释放),再退出。这是非交互式自动化:没有提示符、批准、恢复、第二轮次或 stdin 上下文。已配置工具可以修改启动时所在的工作区、运行命令、spawn 子 agent,并消耗提供方 token。 ## 高级与快照接线 -[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下的测试拥有无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 +[`advanced.cordis.yml`](advanced.cordis.yml) 在已交付叶节点上添加 Code Mode 和 Cordis 工具。[`advanced.cordis.snapshot.yml`](advanced.cordis.snapshot.yml) 只将实时 LLM(大语言模型)替换为回放。[`tests/`](tests/) 下涵盖无密钥真实 Loader 冒烟测试、密钥门控的外部状态验证冒烟测试,以及带父子会话 fixture(测试前置数据)的 `stream-json` 回放快照。 -包级 [CLI 契约](../../packages/examples/cli-demo/README.md)记录输出记录、退出状态、取消、持久化以及模型/token 影响。 +这份包(package)级 [CLI(命令行界面)契约](../../packages/examples/cli-demo/README.md) 说明输出记录、退出状态、取消、持久化以及模型/token 影响。 diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index dc9b6233e7..43290fc365 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -2,16 +2,16 @@ [English](README.md) | 中文 -面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、console logger、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 +面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 面向模型的工具为: - `bash`,仅前台 - `read`、`write` 和 `edit` -- `subagent`,使用一个前台进程内 spawn 提供方 +- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方 - `todo_write` -周边运行时还加载 JSONL 会话持久化和自动上下文压缩(compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 +周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 ## 运行时环境 @@ -24,4 +24,4 @@ | `DSH_SESSION_ROOT` | JSONL 轨迹目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | -通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件命名的每个插件;目标机器无需 Node.js。 +通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index b3f6dc1853..dd9bbaa141 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -2,7 +2,7 @@ [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 提供该能力。 +全屏交互式编码 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 提供该能力。 ## 运行 @@ -13,13 +13,13 @@ pnpm run demo:tui ``` -演示脚本和可安装的 `dsh` CLI([`apps/cli`](../../apps/cli/README.md))都会作为已交付的默认配置启动此示例的 `cordis.yml`;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为 workspace。 +演示脚本和可安装的 `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 -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。fs 工具和 bash 都会根据会话 workspace 解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 +输入一项编码任务。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 历史、推理、工具所有的终端/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` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +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` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -29,11 +29,11 @@ TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡 dsh --resume ``` -`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久 goal 阶段和实时/已持久化状态。已安装的 `dsh` 宿主会刷新并释放当前应用,然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 +`/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](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 +[`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) @@ -54,27 +54,27 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | | `llm-deepseek` | 默认原生适配器 | -| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | +| `bash` (`dsh-bash-local`) | 执行器实现:bash 服务边界中可替换的实现侧。面向模型的 `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` 工具。相对路径根据会话 workspace 解析 | +| `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 根目录,然后释放整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 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` 事件位于父调用下,且筛选后的答案已返回。 +- `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 覆盖欢迎行,以及恢复失败退出路径。 +这些测试在没有 `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 覆盖之间的分工。 +`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/native/README.zh.md b/native/README.zh.md index f73d417645..276db0e655 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`node-addon-landlock-run` 的记录真源:这是 harness 从 npm 消费的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包系列的发布镜像。 +`node-addon-landlock-run` 的权威源码位于此处:这是 harness 从 npm 引入并使用的 Landlock「先限制自身、再执行」启动器(`packages/sandbox/sandbox-local`、`packages/bash/bash-sandbox`)。启动器在此处开发,与消费方相邻;独立仓库是打包并发布 npm 包(package)系列的发布镜像。 ## 发布镜像 @@ -17,6 +17,6 @@ 1. 先通过常规 harness PR 将启动器更改落地于此;触发 `Landlock Run` 工作流,并确保其所有任务通过。 2. 在镜像 checkout 中替换 `.github/` 以外的所有内容:`git -C rm -rq -- . ':!.github'`,然后执行 `git -C archive HEAD:native/landlock-run | tar -x -C `,最后执行 `git -C add -A` 并提交。 3. 在镜像中按照其发布清单(`docs/release.md`)操作:`pnpm release:commit ` → 合并 → 标记 `vX.Y.Z` → 两阶段 `Release` 工作流(先以 `publish=false` 预演,再从标签以 `publish=true` 发布)。 -4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中提升 harness 消费方的依赖范围。 +4. 使用已发布的标签/commit 更新上方 manifest(元数据清单)表,并在同一更改中上调 harness 消费方的依赖版本范围。 -镜像不得分叉:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 +发布镜像不得与此处的权威源码产生分歧:如果更改直接提交到镜像中(例如发布期间的热修复),必须在下次导出前将其移植回此处。 diff --git a/native/landlock-run/README.zh.md b/native/landlock-run/README.zh.md index 7163314aba..f369799cc8 100644 --- a/native/landlock-run/README.zh.md +++ b/native/landlock-run/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以每平台预构建 npm 包加一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要在文件系统允许清单下运行不可信命令、但不能限制自身的 agent harness 和其他宿主。 +一个 [Landlock](https://landlock.io/)「先限制自身、再执行」启动器,用于在 Linux 上限制子进程。它以按平台预构建的 npm 包(package)以及一个轻量 JS 入口包的形式发布;入口包负责解析二进制文件并遵循其 CLI(命令行界面)契约。该启动器面向需要让不可信命令在文件系统允许清单约束下运行、同时保持自身不受限制的 agent harness(智能体框架)和其他宿主。 第一个工具是 **`landlock-run`**:一个「先限制自身、再执行」的 [Landlock](https://landlock.io/) 启动器(基于原始内核 UAPI 编写,约 300 行 C11,并与 musl 静态链接)。它在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此命令及其产生的每个进程都在限制下运行,调用进程仍不受限制。它采用失败闭合:如果内核无法强制执行,则不运行命令并直接退出。 @@ -34,12 +34,12 @@ if (probe(launcher) !== 'unusable') { } ``` -公开 API 有意保持简小: +公开 API 有意保持精简: - `launcherPath()`:当前宿主启动器的绝对路径(有意不检查是否存在;探测结果才是可用性信号)。 - `probe(launcher?, { timeoutMs? })`:功能性强制执行探测,返回 `'full' | 'partial' | 'unusable'`。 - `grantArgs({ readOnly?, readWrite? })`:启动器的授权 argv;未授予的一切都被拒绝。 -- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT` (125):契约常量。 +- `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`(125):契约常量。 完整的二进制契约(argv 语法、退出码、报告行)锁定在 [docs/cli-contract.md](docs/cli-contract.md) 中。 @@ -57,4 +57,4 @@ pnpm build:native # this Linux architecture's binaries (apt-get install musl- pnpm test ``` -二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 的每架构 runner 则是记录中的构建者。发布流程详见 [docs/release.md](docs/release.md)。 +二进制文件被 git 忽略,并且按架构原生构建:本地只构建当前机器的版本,CI 各架构 runner 产出的构建则作为正式发布依据。发布流程详见 [docs/release.md](docs/release.md)。 diff --git a/native/landlock-run/packages/entry/README.zh.md b/native/landlock-run/packages/entry/README.zh.md index 03dd18969d..6f8136c335 100644 --- a/native/landlock-run/packages/entry/README.zh.md +++ b/native/landlock-run/packages/entry/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包解析每平台预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 +用于在 Linux 上限制子进程的 Landlock「先限制自身、再执行」启动器:此入口包(package)定位对应平台的预构建二进制文件,运行功能性强制执行探测,并构建其授权 argv。消费方无需自行拼写启动器标志或解析启动器输出。 ```js import { grantArgs, launcherPath, probe } from 'node-addon-landlock-run'; @@ -13,6 +13,6 @@ if (probe(launcher) !== 'unusable') { } ``` -启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:始终失败闭合,绝不失败开放。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 +启动器在自身上安装 Landlock 规则集,再 `exec` 被包装的命令;该规则集会跨 `execve` 继承,因此整个进程树都在限制下运行。未授予的一切都被拒绝;启动器失败时以 `125` 退出且不运行命令:采用失败闭合策略,绝不在失败时放行。二进制契约锁定在仓库的 `docs/cli-contract.md` 中;C 源码作为 `src/main.c` 随该 tarball 分发,便于审计。 -平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回确定且不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 +平台包(由 `os`/`cpu` 选择的可选依赖,内部不含 JavaScript):`node-addon-landlock-run-linux-x64`、`node-addon-landlock-run-linux-arm64`。在缺少对应包的宿主上,`launcherPath()` 返回一个固定但不存在的路径,`probe()` 报告 `'unusable'`;系统有意不提供安装时编译回退。 diff --git a/native/landlock-run/packages/linux-arm64/README.zh.md b/native/landlock-run/packages/linux-arm64/README.zh.md index 93fee68207..abbd0d1040 100644 --- a/native/landlock-run/packages/linux-arm64/README.zh.md +++ b/native/landlock-run/packages/linux-arm64/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-arm64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 同级包:`node-addon-landlock-run-linux-x64`。 diff --git a/native/landlock-run/packages/linux-x64/README.zh.md b/native/landlock-run/packages/linux-x64/README.zh.md index b1fa2e3f16..e813bcef71 100644 --- a/native/landlock-run/packages/linux-x64/README.zh.md +++ b/native/landlock-run/packages/linux-x64/README.zh.md @@ -2,8 +2,8 @@ [English](README.md) | 中文 -面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个从 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 中随包发布的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其解析为文件路径。该包不包含 JavaScript,也绝不会被导入。 +面向 linux-x64 的预构建 `bin/landlock-run` Landlock 启动器:一个由 [`node-addon-landlock-run`](https://www.npmjs.com/package/node-addon-landlock-run) 包(package)所附的 C 源码原生编译而成的静态 musl 二进制文件(不使用交叉工具链)。npm 的 `os`/`cpu` 字段在安装时选择此包;入口包将其定位到文件路径。该包不包含 JavaScript,也绝不会被导入。 -该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节将打包二进制文件锁定到其来源 CI 构建。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 +该二进制文件被 git 忽略,并通过 `files` 列表进入 npm tarball;如果文件缺失或 ELF 架构错误,`prepack` 门禁会拒绝打包,发布流水线则会按字节核验打包的二进制文件与其来源 CI 构建产物一致。静态 musl 链接使同一个二进制文件同时适用于 glibc 和 musl 发行版,因此名称中没有 libc 后缀。 同级包:`node-addon-landlock-run-linux-arm64`。 diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md index 9999ecdd01..8679f2428a 100644 --- a/packages/acp/README.zh.md +++ b/packages/acp/README.zh.md @@ -6,6 +6,6 @@ ACP(Agent Client Protocol)组将 harness 中的 agent(智能体)公开 | 包 | 职责 | |---|---| -| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接拥有的清理。 | +| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器:新文本会话、已提交的 assistant 输出、机器权限策略、取消和由连接负责的清理。 | -与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以驱动同一服务器契约。 +与之匹配的进程外 subagent 客户端仍位于 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现 subagent 提供方接口;任意 ACP 客户端都可以按照同一服务器契约驱动该服务器。 diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index f8abe9e45a..a937bff27c 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、通过策略解决一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 +通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本提示词、收集已提交的 assistant 文本、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 -此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、mode、配置选择器、信息征集、推理、计划、标题或工具展示。交互渲染与人类问题属于 Web 和 TUI 模块。 +此包(package)是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 和 TUI 模块。 ## 插件 @@ -15,7 +15,7 @@ | `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | | `model` | 无 | 每个已创建 agent 的初始模型。 | -两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行 ACP 组合同时要求两者。 +两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。 ## 协议契约 @@ -23,19 +23,19 @@ |---|---| | `initialize` | 协商受支持的版本,并仅公布基线提示词(无图像、音频或嵌入上下文能力)。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | -| `session/new` | 使用绝对主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 连接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并从该请求拥有的持久 `turn/end` 结算。 | -| `session/cancel` | 仅取消被定址的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | +| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | +| `session/prompt` | 拼接文本块,将基线资源链接渲染为带方括号的文本引用,拒绝空输入或超出基线的输入,每个会话只允许一个正在处理的请求,并根据该请求所属的持久 `turn/end` 结算。 | +| `session/cancel` | 仅取消指定的 agent,并将其待处理提示词结算为 `cancelled`;未知 id 为空操作。 | | `session/update` | 为每个非空文本块发出一个 `agent_message_chunk`;这些文本块来自已提交的 `assistant/message`。省略原始增量和非消息事件。 | -| `session/request_permission` | 为携带工具调用 id 的桥接层所有批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | +| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | -一个连接可以拥有多个会话。桥接层使用带品牌的 session id 为记录建键,并在路由事件或权限请求前检查精确的 agent 标识。每个会话都有独立的提示词槽位、workspace、取消路径和 disposer。 +一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。 -已提交消息输出有意以逐 token 延迟换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 +已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本;推理与工具活动仍保留在会话日志中,以便其他界面观测。 ## 生命周期 -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行释放所有已拥有的 agent handle,并等待它们的循环/会话清理完成。因此,仅 ACP 的插件重载不会遗留 agent。 +客户端断开连接与 Cordis 的 dispose(资源释放)共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,结算待处理提示词,然后并行对其拥有的全部 agent 句柄执行 dispose,并等待它们的循环/会话清理完成。因此,单独重载 ACP 插件不会遗留孤儿 agent。 ## 运行 @@ -47,11 +47,11 @@ #### 模型所见内容 -`session/prompt` 文本块会原样连接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 +`session/prompt` 文本块会原样拼接为一条用户消息;基线资源链接会在该消息中表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。协议元数据、客户端能力、权限选择和 session id 绝不进入模型请求。 #### Token 影响 -提示词 token 取决于数据,并保留在该会话的历史中直到压缩。并发 ACP 会话保留独立上下文。 +提示词 token 取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 #### KV Cache 影响 @@ -61,19 +61,19 @@ #### 模型所见内容 -没有直接内容。拥有该决策的工具通过常规工具结果路径记录允许、拒绝、取消或不可用结果。 +不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。 #### Token 影响 -只有拥有该决策的工具结果会贡献 token。 +只有该工具的结果会贡献 token。 #### KV Cache 影响 -通过所属工具结果仅追加。 +随该工具的结果仅追加。 ## 已知限制与延后工作 - **仅新会话**:不支持加载、列出、恢复、删除和 fork。 -- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接会被展平为文本引用,而不是已获取内容。 -- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不上线。 -- **连接拥有的生命期**:一个连接会释放其所有会话;尚未实现每会话关闭。 +- **仅基线提示词和一个 workspace**:图像、音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 +- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。 +- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。 diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index 57c28b45cf..deb23ea820 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品** 包。 +规范的三包能力 seam(见[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):抽象执行器接口、具体实现,以及消费该接口的面向模型工具。这些全是**产品**包。 | 包 | 职责 | ctx key | |---|---|---| @@ -11,4 +11,4 @@ | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | -接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器配置项;受限实现还需选择一个 `ctx.sandbox` 提供方配置项(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 +接口位于 `bash/bash/`。以 `bash-sandbox` 替换 `bash-local`,同时不改动接口或工具,正是这种拆分存在的意义:叶级 `cordis.yml` 选择一个执行器插件条目;受限实现还需再选择一个 `ctx.sandbox` 提供方插件条目(见 [acp-agent 示例的默认组合](../../examples/acp-agent/))。 diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index aa6de87df4..16c8ca50a0 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并负责所有 Bash 层职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))则由 subprocess 服务负责。 包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流需要时采用。 - **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 -- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带分节标记的增量,并以消费游标记录读取进度。仍在运行的进程则由 subprocess 服务负责,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -36,12 +36,12 @@ #### KV Cache 影响 -不会直接失效;请求前缀变更由具名消费方负责。 +不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。 ## 已知限制与暂缓事项 -- **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 -- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 +- **自身不提供隔离**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 +- **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流需要它们。 - **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 - **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 diff --git a/packages/bash/bash-sandbox/README.zh.md b/packages/bash/bash-sandbox/README.zh.md index c1a65ead53..4ecc8d533f 100644 --- a/packages/bash/bash-sandbox/README.zh.md +++ b/packages/bash/bash-sandbox/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -消费 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 的沙箱实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);后者拥有默认模式 + 工作区根目录,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 +这是使用沙箱能力的 [`@deepseek-ai/dsh-bash`](../bash/) 执行器 seam 实现。加载它时,应**用它替代** `@deepseek-ai/dsh-bash-local`,并同时加载 [`ctx.sandbox`](../../sandbox/sandbox/) 提供方(例如 [`@deepseek-ai/dsh-sandbox-local`](../../sandbox/sandbox-local/))及 [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/);默认模式和工作区根目录由后者负责,并与受沙箱约束的文件系统共享这些设置。无需使用替代工具插件;`dsh-tool-bash` 会检测执行器的 `sandboxMode` 能力并添加升权字段。 包根目录导出默认与具名的 `SandboxBashExecutor` 插件及其 `Config`;引号处理与结果分类 helper 保留在内部。 -每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用(必须快速失败并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默无约束运行),属于提供方职责;本包只拥有 bash 侧。 +每条命令的限制方式都是:把本执行器即将 spawn 的精确 `['bash', '-c', command]` argv 交给提供方,再 spawn 其返回的(已包装)argv。由哪种平台 runner 执行限制,以及是否有 runner 可用,属于提供方职责;若无可用 runner,则按失败关闭原则拒绝执行并返回结构化 `SANDBOX_UNAVAILABLE` 错误,绝不能静默地无约束运行。本包只负责 bash 侧。 | 模式 | 文件影响 | |---|---| @@ -17,12 +17,12 @@ 语义: - **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。 -- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,bash 产生方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 -- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent 调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 +- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。 +- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权。模型只能通过结果事实了解沙箱:静态 bash 工具描述会解释拒绝标记,系统提示词中不会声明当前模式。 - **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。 - 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。 -seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它驱动本包遵守的覆盖。 +该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。 ```yaml - id: sandbox @@ -36,7 +36,7 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 name: '@deepseek-ai/dsh-bash-sandbox' ``` -无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,在真实世界验证,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 +无密钥消费方集成证明是 `tests/bwrap.e2e.ts`、`tests/landlock.e2e.ts` 和 `tests/seatbelt.e2e.ts`(通过 `ctx.bash` 驱动真实提供方 + 真实 runner,从外部验证实际文件效果,并在相应 runner 缺失时各自自行跳过)。agent-spine e2e 还会在一个 Cordis 上下文中驱动两个并发会话,并证明每个真实 bash 工具调用只能写入自身项目。可运行 demo 见 [acp-agent 示例的默认组合](../../../examples/acp-agent/)。 ## 模型体验 @@ -44,11 +44,11 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 #### 模型看到的内容 -基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布一个执行限制的 `sandboxMode`,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 +基线是生成的 [`dsh-tool-bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash)。通过公布表明启用隔离的 `sandboxMode` 能力,此后端会为 `bash` 增加 `sandbox_permissions`,其 enum 为 `workspace-write` | `danger-full-access`,并增加 `justification`。后端不添加提示词文本,会话的有效模式仍不会声明。 #### Token 影响 -在 `bash` 可见的请求上增加少量固定 schema;模式切换不增加上下文 token。 +在 `bash` 可见的请求上,schema 固定增加少量内容;模式切换不增加上下文 token。 #### KV Cache 影响 @@ -62,29 +62,29 @@ seam 上仅拒绝:拒绝是一项已报告事实,本执行器绝不自行协 #### Token 影响 -除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到压缩。 +除普通输出外,正常允许的运行不会增加 token。拒绝或失败会增加上述有条件标记,并保留到上下文压缩(context compaction)。 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ### 间接的 Bash 工具错误 #### 模型看到的内容 -如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误;它由 `dsh-sandbox` 持有](../../sandbox/sandbox/README.md#confinement-error-indirectly)。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 +如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。 #### Token 影响 -该次调用可见的是有条件错误文本,并保留在历史记录中直到压缩。 +该次调用会在相应条件下显示错误文本,该文本会保留在历史记录中直到上下文压缩。 #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。 +仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 ## 已知限制与暂缓事项 - **限制只覆盖文件影响**:网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。 -- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但匹配的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 +- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。 - **后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `task_output` 读取通用任务时呈现。 - **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。 diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 14476b7703..a7c0cac0bc 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -4,7 +4,7 @@ **bash 执行器 seam**:抽象 `BashExecutor` 服务(`ctx.bash`)定义 bash 后端做什么,即运行前台命令与启动后台进程,但不规定如何实现。task id、所有权、收集、取消与通知属于通用 `ctx.tasks` 运行时。 -本包是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): +本包(package)是 bash 能力中负责接口的四分之一,各项职责因此可以独立演进(和替换): | 包 | 职责 | |---|---| @@ -13,19 +13,19 @@ | `@deepseek-ai/dsh-bash-sandbox` | 实现:沿用 `dsh-bash-local` 的机制,但通过 [`ctx.sandbox`](../../sandbox/sandbox/) 限制每次 spawn,并将拒绝报告为结果事实 | | `@deepseek-ai/dsh-tool-bash` | 基于 `ctx.bash`、面向模型的工具 schema | -该拆分与 LLM seam(`LlmService`/`LlmAdapter`)及 agent 工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 +该拆分与 LLM(大语言模型) seam(`LlmService`/`LlmAdapter`)及 agent(智能体)工具调研结果一致:pi 将执行隐藏在 `BashOperations` 接口之后(本地 shell/SSH/VM 后端),Codex 则隐藏在 exec-server 协议之后。`dsh-bash-sandbox` 正是这种替换的实际应用:沙箱执行器位于同一接口之后;消费方检测其 `sandboxMode` 能力并添加升权字段,无需导入实现。容器化或远程执行器也可以同样接入。 ## 服务 API(`ctx.bash`) | 成员 | 语义 | |---|---| -| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止终止都会 resolve 为描述性 `BashRunResult`。 | +| `run(spec)` | 前台执行。命令完成时 resolve。**只会因基础设施失败而 reject**(工作目录不可用、shell 缺失、信号已在调用前中止);非零退出、超时终止和中止导致的终止都会 resolve 为描述性 `BashRunResult`。 | | `start(spec)` | 后台执行。立即返回不含任务语义的 `BashProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.tasks`。 | | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | | `BashProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区边界丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | | `BashProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | -实现会继承 `BashExecutor` 并实现抽象方法。dispose 必须终止每个运行中的进程并等待其退出,详见 HMR 安全测试。 +实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出,详见 HMR(热模块替换)安全测试。 ## 词汇 @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 @@ -41,9 +41,9 @@ #### KV Cache 影响 -不会直接失效;请求前缀变更由具名消费方负责。 +不会直接导致 KV Cache 失效;请求前缀变更由具名消费方负责。 ## 已知限制与暂缓事项 - **没有交互式输入词汇**:`stdin` 只会在 spawn 时写入一次并关闭;seam 不提供向运行中任务继续输入的通道,也没有 PTY 会话概念。 -- **前台超时始终由执行器拥有**:seam 上的调用方拥有 deadline 模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 +- **前台超时始终由执行器负责**:seam 上由调用方负责 deadline 的模式已由 [工具调用超时策略 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md) 明确暂缓。 From 90c75be466cd77d271977b6b7300ae3327afb77d Mon Sep 17 00:00:00 2001 From: j-xiang Date: Wed, 29 Jul 2026 15:29:38 +0800 Subject: [PATCH 131/153] docs(i18n): proofread README translations 21-40 --- packages/client/hmr/README.zh.md | 12 +++++----- packages/client/locale/README.zh.md | 4 ++-- packages/client/modules/README.zh.md | 10 ++++---- packages/client/runtime/README.zh.md | 22 ++++++++--------- packages/client/ui-model/README.zh.md | 18 +++++++------- packages/client/ui-models/README.zh.md | 2 +- packages/client/ui-primitives/README.zh.md | 6 ++--- packages/client/ui-settings/README.zh.md | 2 +- packages/client/ui-sidebar/README.zh.md | 12 +++++----- packages/client/ui-skill/README.zh.md | 12 +++++----- packages/client/ui-slash/README.zh.md | 12 +++++----- packages/client/ui-slots/README.zh.md | 10 ++++---- packages/client/ui-subagent/README.zh.md | 10 ++++---- packages/client/ui-theme/README.zh.md | 8 +++---- packages/client/ui-trajectory/README.zh.md | 6 ++--- packages/client/web-react/README.zh.md | 6 ++--- packages/client/web/README.zh.md | 10 ++++---- packages/code-runtime/README.zh.md | 6 ++--- .../code-runtime-worker/README.zh.md | 24 +++++++++---------- .../code-runtime/code-runtime/README.zh.md | 16 ++++++------- 20 files changed, 104 insertions(+), 104 deletions(-) diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 6d94ca4a5e..58fbad900d 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -为通过 fetch 到达的客户端插件提供热重载。该静态到达配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略此行,因此外壳打包的代码保持不活动。 +为通过 fetch 加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。 -浏览器侧订阅系统 SSE 通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行(组合包交接 slot 只能容纳一个)。每帧的顺序是:`prefetch`(在触碰任何内容前抓取新组合包)、`invalidate`、`registry.delete`(在 fiber 之前执行:只释放 fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `