merge: refresh against the advancing master

Merges master (default-workspace-write-ui, installer-adopt-checkout,
remove-scoped-bash, goal-clear-single-flight, frontend-plugin-loader,
install-interface-choice, …).

Conflict resolutions:
- apps/cli/tests/shipped-composition.e2e.ts: keep the fixed glob/grep
  roster assertion; master's workspace-write composition now confines
  tool-bash, so the escalation pair is pinned present (my earlier
  absence pin is superseded) together with master's permission facts.
- even-out-shipped-tool-rosters note: both sides edited it; the merged
  text keeps both sets of changes and the pair is re-recorded.
This commit is contained in:
Huanqi Cao
2026-08-01 22:22:27 +08:00
138 changed files with 1351 additions and 499 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
2026-07-19-gui-web-client-architecture.md: 63b6f5795c3d49f25cd964cf04a0c9d41a667bfb 2026-07-19-gui-web-client-architecture.md: b1f777172774f1cf8fef4d9494f15b38064d0c73
2026-07-19-gui-web-client-architecture.zh.md: 2d57c12ebae38aafa4e606da95af954990761b3c 2026-07-19-gui-web-client-architecture.zh.md: e43151b7d5ff096d574c786e3aae107523d22c96

View File

@@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien
A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md).
There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The 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` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Session-dimension differentiation happens inside the component — `useSessions` reading `parentId` — not in registry predicates; interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The 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` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`, with `inject: ['slots', 'conversation']` as the load-order seam (the conversation service being present guarantees the slot is declared). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early.
**Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport).

View File

@@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain-
服务是插件对其他插件的唯一 API 面UI 组件与注入面都不是 API无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 服务是插件对其他插件的唯一 API 面UI 组件与注入面都不是 API无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader``ctx.theme``ctx.i18n``ctx.layout`(跨插件视图导航)、`ctx.conversation`send/cancel/startSession。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。
slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 options`id`/`order`/`label`per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`keyed/session由 chat 条目的 `children` 表声明key 空间运行时开放SlotMap 声明槽、从不声明 key这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails``ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝conversation 服务在场即保证槽已声明)。会话维差异化在组件内完成——`useSessions``parentId`——不走注册表谓词;交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entrytab 元数据随注册 options`id`/`order`/`label`per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`keyed/session由 chat 条目的 `children` 表声明key 空间运行时开放SlotMap 声明槽、从不声明 key这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails``ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝conversation 服务在场即保证槽已声明)。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。
**scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同no-op 插件 fiber + scope 键 extend首次观看时惰性建只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope冻结为只读视窗 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同no-op 插件 fiber + scope 键 extend首次观看时惰性建只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope冻结为只读视窗

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md
2026-07-23-toolview-dissolution.md: 80c2688b152d1afe1236d4815633a5bf024db1d2 2026-07-23-toolview-dissolution.md: 406e5c181aabb635f9d6dcb12d8a9b8b6697368e
2026-07-23-toolview-dissolution.zh.md: 928c5f445d601b2246d3ae2f9360232643814468 2026-07-23-toolview-dissolution.zh.md: 311affcbd9605ff81b78e974f75ee83328d93f68

View File

@@ -14,13 +14,13 @@ After the view ring dissolved into the slot system, the client kept exactly one
The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively.
Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', 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, by construction. Session-dimension differentiation happens inside the component (`useSessions` reading `parentId` — the decision sits where all the information already is); the bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`, with a scoped badge only in child sessions). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', 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, by construction. The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `<domain>.<entry>.<hole>`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations.
Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option.
## Accepted semantic changes ## Accepted semantic changes
Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch moved from registry predicates into the component. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry.
## Alternatives considered ## Alternatives considered

View File

@@ -14,13 +14,13 @@ Status: implemented
工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放SlotMap 声明槽、从不声明 keyask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。
落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`默认卡片是域产权fallback 选项就是普通 renderSlot 文法。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*,故服务在场即保证槽已声明,构造使然。会话维差异化在组件内完成(`useSessions``parentId`——决策放在已有全部信息的地方);bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome`Bash · {description}`scoped badge 仅出现在子会话。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)chat 条目的 `children` 表声明 `'conversation.chat.toolview'`keyed/session渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`默认卡片是域产权fallback 选项就是普通 renderSlot 文法。owner 载荷是统一的 `ToolRowOwnerProps``callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作加载序缝——apply 把 `ConversationService` 挂在 chat 注册*之后*故服务在场即保证槽已声明构造使然。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome`Bash · {description}`。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。
registry 时代的职责各有后继居所inject 缓存与行错误隔离乘框架渲染器entry×scope 缓存、per-entry `SlotErrorBoundary`subscribe/getVersion 乘 slot core 的 per-key 版本机将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位交互草稿耐久性是其首个具名消费者miss 兜底即调用点 `fallback` 选项。 registry 时代的职责各有后继居所inject 缓存与行错误隔离乘框架渲染器entry×scope 缓存、per-entry `SlotErrorBoundary`subscribe/getVersion 乘 slot core 的 per-key 版本机将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位交互草稿耐久性是其首个具名消费者miss 兜底即调用点 `fallback` 选项。
## 接受的语义变化 ## 接受的语义变化
四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发从注册表谓词移入组件。第三方在 registry 级覆盖形态scoped 注册压过 global不复存在真出现的未来需求走 key 命名空间约定或组件内小 resolver永不复活平行注册表。 四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态scoped 注册压过 global不复存在真出现的未来需求走 key 命名空间约定或组件内小 resolver永不复活平行注册表。
## Alternatives considered ## Alternatives considered

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
2026-07-28-launcher-owned-resume-identity.md: 167c9e848a9101c9d1e93cf3af968b00279db32b 2026-07-28-launcher-owned-resume-identity.md: da9b4571d154137d34ef3690e7b4aa9bc7bb9082
2026-07-28-launcher-owned-resume-identity.zh.md: 218b69581e65e8566ff1047603bb71c9537d3486 2026-07-28-launcher-owned-resume-identity.zh.md: 51ccffd7bb9c8eeda03afe2528d3cb4250e2d906

View File

@@ -10,7 +10,7 @@ Two facts a launcher owns were shipped as deployment config keys on the TUI app
Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured".
Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume <valid-id>` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume <valid-id>` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause.
A config key cannot express these facts safely, because the deployment is not the authority on them. A config key cannot express these facts safely, because the deployment is not the authority on them.
@@ -25,7 +25,7 @@ Both sit beside the existing `tuiResumeHost` host capability, which set the prec
Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md).
`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume <id>`; `dsh experimental-meta` accepts no options and always starts fresh. `apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume <id>`; `dsh meta` accepts no default-surface options and always starts fresh.
**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. **`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly.

View File

@@ -10,7 +10,7 @@ Status: implemented
把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。
两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume <valid-id>` 会开启一个*全新*会话且什么都不说并被直接复现banner 显示的是一个新铸造的 id而非所请求的那个。[`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume <valid-id>` 会开启一个*全新*会话且什么都不说并被直接复现banner 显示的是一个新铸造的 id而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。
一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。
@@ -25,7 +25,7 @@ Status: implemented
身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。
`apps/cli` 铸造或选定 id并依据它所复现的那次调用构建该行`/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume <id>` 重新进入默认界面;`dsh experimental-meta` 不接受任何选项,并且总是启动新会话。 `apps/cli` 铸造或选定 id并依据它所复现的那次调用构建该行`/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume <id>` 重新进入默认界面;`dsh meta` 不接受任何默认界面选项,并且总是启动新会话。
**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }``vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 **`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }``vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。

View File

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

View File

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

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md
2026-07-22-docked-web-goal-bar.md: f014da61d2fa0bf25121c040dae99354ab15de9d 2026-07-22-docked-web-goal-bar.md: 30f1d45e80cb2759175948f5683b499720ab50f0
2026-07-22-docked-web-goal-bar.zh.md: f62c6efbb2d330fb7d5ab74138eb781f1a1bc06c 2026-07-22-docked-web-goal-bar.zh.md: 4c8481e64d9e5177a962f10ab1d043e761d07545

View File

@@ -12,7 +12,7 @@ The web UI had no goal surface at all: the goal stack shipped with model tools,
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. `GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. Every mutation first acquires a synchronous component-local single-flight latch because React's pending-state render cannot close the same-frame click window. A successful clear also suppresses that exact goal id immediately while the authoritative null projection catches up, so an acknowledged tombstone cannot leave a stale clear control that submits `GOAL_NOT_FOUND`; a failure releases the latch and remains retryable. An effect keyed on the goal's id resets this transient state and drops the edit form when the goal's identity changes, so neither a cleared marker nor a surviving draft can affect the replacement goal.
`GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref. `GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
@@ -22,7 +22,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
## Testing ## Testing
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions. `packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, rapid same-frame clear clicks dispatch once and a successful clear hides before projection convergence, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible and retryable in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
## Alternatives considered ## Alternatives considered
@@ -34,6 +34,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
## Consequences ## Consequences
- Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. - Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
- Goal mutations are single-flight within the component; a successful clear hides its exact goal immediately while projection delivery converges, preventing duplicate CAS errors without making transient UI state authoritative.
- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). - The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). - Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools).
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.

View File

@@ -12,7 +12,7 @@ Web UI 此前没有任何目标相关的界面目标栈已随模型工具、T
`GoalBar``packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 `GoalBar``packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
可见性决定标签和操作active 状态显示 "Ongoing Goal" 并提供暂停编辑清除paused 状态显示 "Paused Goal"把暂停换成一个恢复图标按钮blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上不在横条里。铅笔图标把横条切换为内联编辑表单预填当前目标内容Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留草稿绝不可能覆盖掉替换它的新目标。 可见性决定标签和操作active 状态显示 "Ongoing Goal" 并提供暂停编辑清除paused 状态显示 "Paused Goal"把暂停换成一个恢复图标按钮blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上不在横条里。铅笔图标把横条切换为内联编辑表单预填当前目标内容Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。每次变更都会先取得一个同步的组件内 single-flight 锁,因为 React 的 pending 状态渲染无法关闭同一帧内的点击窗口。清除成功后还会立即抑制该 goal id直到权威的 null 投影追上,因此已确认的墓碑不会留下陈旧的清除控件并再次提交 `GOAL_NOT_FOUND`;失败则释放锁,并且仍可重试。一个以目标 id 为键的 effect 会在目标身份变化时重置瞬态状态并丢弃编辑表单,因此无论已清除标记还是存留草稿,都不会影响替换目标。
`GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref因此 UI 不传 ref。 `GoalBarActions` 位于 ui-goal 的槽位契约(`packages/client/ui-goal/src/client/slots.ts`),只携带实际渲染的动词:`onEdit`/`onPause`/`onResume`/`onClear`。每个回调都会异步返回显式成功/失败结果,因此 `GoalBar` 自行负责界面转换和错误显示。`apply.ts` 把它们接到运行时会话方法上;运行时会话在内部解析当前目标的 compare-and-set ref因此 UI 不传 ref。
@@ -22,7 +22,7 @@ Web UI 此前没有任何目标相关的界面目标栈已随模型工具、T
## 测试 ## 测试
`packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为加载中无目标已完成时不渲染active 横条渲染标签和目标内容并触发清除;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消并在目标身份变化时重置active 横条触发暂停paused 横条触发恢复blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿并且编辑恢复清除错误持续显示在横条中。skeleton 规格测试分别挂载带与不带 `goalActions``ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。 `packages/client/ui-goal/tests/goalbar.spec.tsx` 仅通过 props 固定这些行为加载中无目标已完成时不渲染active 横条渲染标签和目标内容并触发清除;同一帧内快速连续点击清除只会分发一次,清除成功后横条会在投影收敛前隐藏;编辑表单预填内容、拒绝空值、按 Enter 保存、按 Esc 取消并在目标身份变化时重置active 横条触发暂停paused 横条触发恢复blocked 横条暴露原因悬浮提示。组件失败路径用例证明编辑失败时保留草稿,并且编辑/恢复/清除错误持续显示在横条中且可重试。skeleton 规格测试分别挂载带与不带 `goalActions``ConversationRoot`;未定义的情形预置了一个 active 目标,因此隐藏横条的是缺失的挂载门,而不是缺失的目标。运行时会话规格测试固定了折叠错误结果、仅 live 的执行中读取加尾随读取,以及陈旧读取守卫。一个无密钥真实浏览器冒烟测试通过 `boot → RPC → runtime → GoalBar` 启动组装后的应用,并以内联快照记录渲染出的标签、目标内容和操作。
## 考虑过的替代方案 ## 考虑过的替代方案
@@ -34,6 +34,7 @@ Web UI 此前没有任何目标相关的界面目标栈已随模型工具、T
## 后果 ## 后果
- Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 - Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。
- 目标变更在组件内走 single-flight清除成功后会在投影投递收敛期间立即隐藏与其 id 完全匹配的目标,既防止重复 CAS 错误,又不会把瞬态 UI 状态视为权威。
- 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 - 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。
- 目标内容首次可以从 UI 编辑,经由 `goal.edit`ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 - 目标内容首次可以从 UI 编辑,经由 `goal.edit`ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。
- `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md
2026-07-27-web-session-fork-actions.md: b5dc7e820de069a68b38ed87c7d29ffbdb4867bc 2026-07-27-web-session-fork-actions.md: 58960169a2e499d953840e5769e7689b5cd48047
2026-07-27-web-session-fork-actions.zh.md: 774cd74d69eb02d43ca01c8ec7ba1cf94c24b1dc 2026-07-27-web-session-fork-actions.zh.md: ea2f9030f672f00fb91bce3546836689a7d41004

View File

@@ -14,7 +14,7 @@ The Web Session-row menu and message IconActions share the client runtime's `ses
`forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation.
Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage, tool presentation, and later queries, but does not control session-list visibility. Session lineage is not projected into a list hierarchy. WorkSpace mode displays source sessions and all fork children as peer rows in the manual order from `WorkspaceView.sessionIds`; every row can be opened, searched, and dragged independently. In one list mode continues to sort strictly by `updatedAt`; the Ungrouped group also sorts by recency when no workspace ledger is available. `parentId` remains available for lineage and later queries, but does not control session-list visibility.
## Alternatives considered ## Alternatives considered

View File

@@ -14,7 +14,7 @@ Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessio
`forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。
Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行每行都可独立打开、搜索和拖拽In one list 模式继续按 `updatedAt` 严格排序Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage、工具呈现和后续查询,但不控制 session 列表可见性。 Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.sessionIds` 的手动序把源会话与所有 fork 子会话显示为同级行每行都可独立打开、搜索和拖拽In one list 模式继续按 `updatedAt` 严格排序Ungrouped 组在没有 workspace 账本时也按 recency 排序。`parentId` 仍用于 lineage 和后续查询,但不控制 session 列表可见性。
## Alternatives considered ## Alternatives considered

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
2026-07-28-dsh-guided-skill-session-commands.md: 9d5341880e92d88781278238f21380919c962820 2026-07-28-dsh-guided-skill-session-commands.md: 8a091f7a03c85b0723d96b4fc546875a4e6c0f95
2026-07-28-dsh-guided-skill-session-commands.zh.md: 90c47d64173161fa4ef1f975de37b9cd42e02c99 2026-07-28-dsh-guided-skill-session-commands.zh.md: 861e6cf6cf07c41fde4f78b0833e3f8eb9508767

View File

@@ -1,4 +1,4 @@
# Agent Note: `dsh migrate`/`dsh experimental-upgrade` seed the first turn with a skill # Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill
Status: implemented Status: implemented
@@ -10,13 +10,13 @@ Two recurring flows begin with the user manually invoking one skill and answerin
## Decision ## Decision
`dsh migrate` and `dsh experimental-upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:<name>` and pressed Enter. `dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:<name>` and pressed Enter.
The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:<name>` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:<name>` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set.
**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume <id>` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume <id>` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup.
**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. **`migrate`/`upgrade` take no default-surface options** (`upgrade` additionally carries the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`). They carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`.
The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent.
@@ -26,7 +26,7 @@ The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED
`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. `packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`.
No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh experimental-upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery.
## Alternatives considered ## Alternatives considered

View File

@@ -1,4 +1,4 @@
# Agent Note`dsh migrate`/`dsh experimental-upgrade` 以 skill 播种首轮 # Agent Note`dsh migrate`/`dsh upgrade` 以 skill 播种首轮
Status: implemented Status: implemented
@@ -10,13 +10,13 @@ Status: implemented
## 决策 ## 决策
`dsh migrate``dsh experimental-upgrade` 以全新会话启动普通 TUI其首轮自动调用一个内置 skill`dsh-migrate``dsh-upgrade`),效果等同于用户键入 `/skill:<name>` 并回车。 `dsh migrate``dsh upgrade` 以全新会话启动普通 TUI其首轮自动调用一个内置 skill`dsh-migrate``dsh-upgrade`),效果等同于用户键入 `/skill:<name>` 并回车。
播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:<name>` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY``tuiInitialSkill`)把 skill 名称传给 TUI`CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill``ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')` 播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:<name>` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY``tuiInitialSkill`)把 skill 名称传给 TUI`CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill``ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`
**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。
**`migrate`/`upgrade` 不接受任何选项**`meta` 不同,它们不带 `--resume``--config``-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'``bin.ts` 将 mode 映射为 `dsh-${mode}` **`migrate`/`upgrade` 不接受任何默认界面选项**`upgrade` 另带[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental`)。它们不带 `--resume``--config``-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'``bin.ts` 将 mode 映射为 `dsh-${mode}`
`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent它会先询问是哪个opencode/pi/Claude Code/Codex再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}``AGENTS.md`/`CLAUDE.md``mcporter`)落地;当某能力无等价物时明确说明。 `dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent它会先询问是哪个opencode/pi/Claude Code/Codex再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}``AGENTS.md`/`CLAUDE.md``mcporter`)落地;当某能力无等价物时明确说明。
@@ -26,7 +26,7 @@ Status: implemented
`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 `packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。
无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent`dsh experimental-upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。
## 考虑过的替代方案 ## 考虑过的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
2026-07-28-dsh-meta-source-workspace.md: be414ddbf63dd95791f9ca956b2f345b1fc8c685 2026-07-28-dsh-meta-source-workspace.md: 95270a276cd5df03ffd2dfb419a33289d7b5b901
2026-07-28-dsh-meta-source-workspace.zh.md: 86fc135db290766cb6fc2abefb194120416bfd9d 2026-07-28-dsh-meta-source-workspace.zh.md: 645b20705386a3501d026fb58ca224c49cc69a17

View File

@@ -1,4 +1,4 @@
# Agent Note: `dsh experimental-meta` boots the TUI over the harness checkout # Agent Note: `dsh meta` boots the TUI over the harness checkout
Status: implemented Status: implemented
@@ -10,19 +10,19 @@ English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md)
## Decision ## Decision
`dsh experimental-meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. `dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory.
The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts``fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts``fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active.
The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `experimental-meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent.
`experimental-meta` always starts a fresh session and accepts no options. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume <id>`. Any leaked default-surface option fails loud. `meta` always starts a fresh session and accepts no default-surface options; its only option is the [experimental gate](2026-07-31-experimental-subcommand-gate.md)'s `--experimental`. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume <id>`. Any leaked default-surface option fails loud.
## Testing ## Testing
`apps/cli/tests/args.spec.ts` pins routing for `experimental-meta`, rejection of every leaked default-surface option, and rejection of the former `meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block. `apps/cli/tests/args.spec.ts` pins routing for `meta`, rejection of every leaked default-surface option, and rejection of the former `experimental-meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block.
There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh experimental-meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir.
The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one.
@@ -30,12 +30,12 @@ The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool
**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. **Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it.
**An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `experimental-meta` alongside `web` matches the existing shape. **An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape.
**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. **Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another.
## Consequences ## Consequences
Starting a session on dsh's own source is `dsh experimental-meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume <id>` later restores the session and enters its persisted workspace. Starting a session on dsh's own source is `dsh meta --experimental` from anywhere (or bare `dsh meta` under `DSH_EXPERIMENTAL=1`), and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume <id>` later restores the session and enters its persisted workspace.
`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. `runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it.

View File

@@ -1,4 +1,4 @@
# Agent Note`dsh experimental-meta` 以 harness 检出为 workspace 启动 TUI # Agent Note`dsh meta` 以 harness 检出为 workspace 启动 TUI
Status: implemented Status: implemented
@@ -10,19 +10,19 @@ Status: implemented
## Decision ## Decision
`dsh experimental-meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 `dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。
目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。
机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `experimental-meta` 分派会传入。在已交付的配置树中cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG``SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode所以配置解析与 chdir 无关。 机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `meta` 分派会传入。在已交付的配置树中cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG``SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode所以配置解析与 chdir 无关。
`experimental-meta` 始终启动新会话,且不接受任何选项`--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume <id>` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。 `meta` 始终启动新会话,且不接受任何默认界面选项;它唯一的选项是[实验性门槛](2026-07-31-experimental-subcommand-gate.md)的 `--experimental``--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume <id>` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。
## Testing ## Testing
`apps/cli/tests/args.spec.ts` 钉住 `experimental-meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。 `apps/cli/tests/args.spec.ts` 钉住 `meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `experimental-meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。
该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd`dsh experimental-meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam本 note 不予采纳。 该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd`dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam本 note 不予采纳。
取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。
@@ -30,12 +30,12 @@ Status: implemented
**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd`!!js process.cwd()``persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 **通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd`!!js process.cwd()``persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。
**在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`experimental-meta``web` 并列符合既有形态。 **在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta``web` 并列符合既有形态。
**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 **解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。
## Consequences ## Consequences
在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh experimental-meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume <id>` 会恢复该会话并进入其已持久化的 workspace。 在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta --experimental`(在 `DSH_EXPERIMENTAL=1` 下可直接执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume <id>` 会恢复该会话并进入其已持久化的 workspace。
`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 `runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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-skill-invocation-policy.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md
2026-07-28-skill-invocation-policy.md: e639db39c0e971ea6988ef6e9801ab71f8d1337f 2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140
2026-07-28-skill-invocation-policy.zh.md: 7e68f2cf28fbaa734dff477441fc8ab73e7367b7 2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917

View File

@@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh experimental-upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
These rules permit all four combinations: These rules permit all four combinations:

View File

@@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill并给出有针对性的警告本仓库尚处于发布前阶段因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则因为忽略这类数据会默认授予权限可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse``metadata` 值会被省略,因为它们不参与调用判定。 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation``user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false``yes`/`no``on`/`off``1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill并给出有针对性的警告本仓库尚处于发布前阶段因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则因为忽略这类数据会默认授予权限可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse``metadata` 值会被省略,因为它们不参与调用判定。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate``dsh experimental-upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill本次改动不新增让浏览器直接加载 skill 的 RPC。 面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate``dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill本次改动不新增让浏览器直接加载 skill 的 RPC。
这些规则允许以下四种组合: 这些规则允许以下四种组合:

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md
2026-07-31-even-out-shipped-tool-rosters.md: d5f1d714ab538b740c25bdf148df285567c616ca 2026-07-31-even-out-shipped-tool-rosters.md: 67ca2759f6d9de3063c80e091d12bd51e2449b58
2026-07-31-even-out-shipped-tool-rosters.zh.md: b09fb43ab66f3784f1a3a3f2a6ee74e185fbdad8 2026-07-31-even-out-shipped-tool-rosters.zh.md: 92627eff2bc2f055762b95118e4abe940da03a91

View File

@@ -16,7 +16,7 @@ The rows that are not surface-specific move into [`base.cordis.yml`](../../../..
Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search.
**This change adds only.** No row is removed from either surface and no existing row's configuration is edited: the executors, the sandbox composition, the access defaults, `tools.mode`, and the workflow tool are exactly what they were. A reader comparing the two catalogs before and after should find additions and nothing else. **This roster decision adds only.** No tool row is removed from either surface, and a catalog comparison finds additions and nothing else. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md).
### What stays unmounted, and why ### What stays unmounted, and why
@@ -42,7 +42,7 @@ The layer that would make MCP a default is the one this repository does not have
That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic. That tail also inserts [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts), which announces settled Loader activation on the terminal stream. The TUI renders as soon as its own fiber starts, so a prompt typed at the banner can reach the loop while tool rows and persistence are still activating and assemble a partial catalog; gating the smoke's first prompt on that marker is what makes the assertion deterministic.
The same smoke pins the TUI's unchanged execution posture from the same artifact: `tool-bash` emits its `sandbox_permissions` escalation pair only when the mounted executor has wider modes to escalate to, so asserting its **absence** fails if a later change quietly sandboxes this surface. The same smoke also pins the TUI execution posture from the same artifact. Those sandbox-schema and initial-permission assertions belong to the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md), independently of this roster.
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)). [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) covers the Web surface in the built lane, asserting its catalog, that its access default is untouched, and that `workspace-write`'s writable roots include the temp directories — a trap that makes sandbox tests lie when the workspace sits under `/tmp` ([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts)).
@@ -66,4 +66,4 @@ The same model gets the same tools on both surfaces, and the difference that exi
`apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can. `apps/cli` gains five workspace dependencies: four the shipped tree now mounts, plus `dsh-mcp-client`, which it does not mount and which exists so an installed `dsh` can.
Nothing about execution changed. The TUI still runs the model's commands through unrestricted executors with no approval seam, and the Web surface still defaults to `danger-full-access`. Both are pinned by assertions in this change, which makes them visible rather than fixed — the sandbox decision is still open. Execution policy stays independent of the roster. The [shared workspace-write decision](2026-07-31-workspace-write-surface-default.md) owns both surfaces' sandboxed executors and default permission; changing that policy does not add or remove a tool.

View File

@@ -16,7 +16,7 @@ Status: implemented
有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。
**本次改动只做加法。** 两个 surface 都没有任何一行被移除,也没有任何既有行的配置被编辑:执行器、沙箱组合访问默认值`tools.mode` 以及 workflow 工具,全都保持原样。对比改动前后的两份目录,读者应当只看到新增,别无其他 **本次工具清单决策只做加法。** 两个 surface 均未移除任何工具行,目录对比只会发现新增,别无其他。共享执行器、沙箱组合访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)
### 什么保持不挂,以及为什么 ### 什么保持不挂,以及为什么
@@ -42,7 +42,7 @@ Status: implemented
该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。 该尾部还插入了 [`composition-settled.ts`](../../../../apps/cli/tests/fixtures/composition-settled.ts),它在终端字节流上宣告 Loader 激活已 settle。TUI 在自己的 fiber 一启动就渲染,因此在 banner 处敲下的提示词可能在工具行与持久化仍在激活时就抵达循环,从而组装出不完整的目录;把冒烟的首个提示词 gate 在该标记上,正是断言得以确定的原因。
同一份冒烟还同一份产物上钉住 TUI 未改变的执行姿态:`tool-bash` 只在挂载的执行器确实有更宽模式可升级时才发出 `sandbox_permissions` 升级参数对,因此断言它的**缺席**会在日后有人悄悄给这个 surface 加上沙箱时失败 同一份冒烟还根据同一份产物固定 TUI 的执行姿态。那些沙箱 schema 与初始权限断言归[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)所有,独立于本工具清单决策
[`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。 [`apps/web/tests/shipped-composition.e2e.ts`](../../../../apps/web/tests/shipped-composition.e2e.ts) 在构建产物 lane 中覆盖 Web surface,断言它的工具目录、它的访问默认值未被触碰,以及 `workspace-write` 的可写根包含临时目录——一个会让沙箱测试说谎的陷阱,当工作区落在 `/tmp` 下时([`roots.ts`](../../../../packages/sandbox/sandbox/src/roots.ts))。
@@ -66,4 +66,4 @@ Status: implemented
`apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。 `apps/cli` 增加五个 workspace 依赖:四个是交付树现在挂载的,外加 `dsh-mcp-client`——它并不被挂载,存在的意义是让已安装的 `dsh` 能挂。
执行相关的一切都没有变。TUI 仍以不受限执行器运行模型的命令且没有批准接缝,Web surface 仍默认 `danger-full-access`。两者都由本次改动中的断言钉住,这让它们变得可见而非被修复——沙箱那个决定仍然悬着 执行策略独立于工具清单。[共享 workspace-write 决策](2026-07-31-workspace-write-surface-default.md)拥有两个 surface 的沙箱执行器与默认权限;更改该策略不会增加或移除工具

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-experimental-subcommand-gate.md
2026-07-31-experimental-subcommand-gate.md: 41447c2d23bc71964f990298de3c48b2fe7ef309
2026-07-31-experimental-subcommand-gate.zh.md: 35598a61d98ff7490b34d175fc915043f5b5236a

View File

@@ -0,0 +1,35 @@
# Agent Note: experimental subcommands gate behind `--experimental` or `DSH_EXPERIMENTAL=1`
Status: implemented
English | [中文](2026-07-31-experimental-subcommand-gate.zh.md)
## Problem
The `meta` and `upgrade` entry points carried their experimental status in their names: `dsh experimental-meta` and `dsh experimental-upgrade`. The prefix made every invocation verbose, and renaming a command at stabilization would break every reference to it — muscle memory, scripts, and docs alike. The status belongs in an opt-in gate, not in the name.
## Decision
`dsh experimental-meta` is `dsh meta` and `dsh experimental-upgrade` is `dsh upgrade`. Each runs only when the invocation passes its `--experimental` flag or the environment carries `DSH_EXPERIMENTAL=1`; otherwise the command fails loud on stderr with exit 1, naming both opt-ins. Per the pre-release stance, the old names are gone with no aliases, and `args.spec.ts` pins their rejection.
The gate has two halves with one owner each. The per-invocation half is a Commander `--experimental` option on each experimental subcommand, checked inside its action after the leaked-parent-option rejection. The environment half is a boolean `parseDshArgs` parameter: `bin.ts` reads `process.env.DSH_EXPERIMENTAL === '1'` at the process boundary (after `loadEnv`, so a project `.env` can set it) and passes the result down, so the parser's environment dependency is explicit in its signature and the tests need no env mutation. `1` is the only enabling value — the variable is an explicit opt-in, not a truthiness check.
Stabilizing a command later means deleting its `--experimental` option and `requireExperimental` call; the name does not move.
## Testing
`args.spec.ts` pins both admit paths, bare-name rejection, old-name rejection, and leaked-option rejection under the env opt-in. `built-bin.e2e.ts` proves the assembled entry end to end: the gate diagnostic on stderr with exit 1, and that `--experimental`, `DSH_EXPERIMENTAL=1`, but not `DSH_EXPERIMENTAL=0`, reach the TUI's piped-stdio refusal — the next gate past this one. Both gated commands were also verified interactively in tmux: `dsh meta --experimental` and `DSH_EXPERIMENTAL=1 dsh meta` boot the TUI over the checkout, and `DSH_EXPERIMENTAL=1 dsh upgrade` seeds the `dsh-upgrade` skill.
## Alternatives considered
**Keep the `experimental-` name prefix.** Rejected by the user's direction: the prefix taxes every invocation, and stabilization would be a breaking rename instead of deleting a gate.
**A parent-level `--experimental` flag (`dsh --experimental meta`).** Rejected: the default surface is deliberately option-only with `enablePositionalOptions`, so parent options that leak across the subcommand boundary are treated as mistyped invocations. A parent flag consumed only by two subcommands would be exactly the leaked-option shape the adapter rejects everywhere else.
**Read `process.env` inside `parseDshArgs`.** Rejected: the repo validates at the process boundary and keeps typed seams pure; tests would have to mutate and restore `process.env` around each case.
**Accept any non-empty `DSH_EXPERIMENTAL`.** Rejected: the telemetry switch prefers off-by-mistake for a privacy control, but an experimental gate is an acknowledgement — `DSH_EXPERIMENTAL=0` must not enable the commands it names.
## Consequences
Daily invocations shorten to `dsh meta --experimental` and `dsh upgrade --experimental`, and a developer who sets `DSH_EXPERIMENTAL=1` in their environment gets the bare `dsh meta`/`dsh upgrade`. `dsh --help` marks both commands `(experimental)`. The gate costs one extra flag or env var until a command stabilizes, at which point the gate is deleted and the name is already final.

View File

@@ -0,0 +1,35 @@
# Agent Note实验性子命令由 `--experimental` 或 `DSH_EXPERIMENTAL=1` 把守
Status: implemented
[English](2026-07-31-experimental-subcommand-gate.md) | 中文
## Problem
`meta``upgrade` 两个入口把实验性状态写在名字里:`dsh experimental-meta``dsh experimental-upgrade`。前缀让每次调用都变得冗长,而在稳定时重命名命令会破坏对它的所有引用——肌肉记忆、脚本与文档皆然。这种状态应当由一个显式选择加入的门槛承载,而不是由名字承载。
## Decision
`dsh experimental-meta` 改为 `dsh meta``dsh experimental-upgrade` 改为 `dsh upgrade`。二者只有在调用时传入各自的 `--experimental` 标志、或环境中带有 `DSH_EXPERIMENTAL=1` 时才会运行;否则命令在 stderr 上明确报错并以退出码 1 结束,同时指明两种选择加入方式。依据发布前立场,旧名称已移除且没有别名,`args.spec.ts` 钉住了对它们的拒绝。
该门槛分为两半,各有其归属。按调用的一半是每个实验性子命令上的 Commander `--experimental` 选项,在其 action 内、泄漏父级选项的拒绝之后检查。环境的一半是 `parseDshArgs` 的一个布尔参数:`bin.ts` 在进程边界读取 `process.env.DSH_EXPERIMENTAL === '1'`(在 `loadEnv` 之后,因此项目 `.env` 也可以设置它)并向下传递结果,因此解析器对环境的依赖显式体现在签名中,测试也无需改动环境变量。`1` 是唯一的启用值——该变量是显式的选择加入,而不是真值判断。
之后要稳定某个命令,只需删除它的 `--experimental` 选项和 `requireExperimental` 调用;名字不再变动。
## Testing
`args.spec.ts` 钉住两条准入路径、裸名称拒绝、旧名称拒绝,以及在环境选择加入下对泄漏选项的拒绝。`built-bin.e2e.ts` 端到端地证明组装后的入口stderr 上的门槛诊断与退出码 1以及 `--experimental``DSH_EXPERIMENTAL=1`(而非 `DSH_EXPERIMENTAL=0`)会到达 TUI 的管道 stdio 拒绝——即此门之后的下一道关卡。两个被把守的命令还在 tmux 中做了交互式验证:`dsh meta --experimental``DSH_EXPERIMENTAL=1 dsh meta` 以检出目录为 workspace 启动 TUI`DSH_EXPERIMENTAL=1 dsh upgrade` 播种 `dsh-upgrade` skill。
## Alternatives considered
**保留 `experimental-` 名称前缀。** 按用户的指示拒绝:前缀让每次调用都付出代价,稳定时也会变成破坏性的重命名,而不是删除一个门槛。
**父级 `--experimental` 标志(`dsh --experimental meta`)。** 拒绝:默认界面刻意保持纯选项形式并启用 `enablePositionalOptions`,跨子命令边界泄漏的父级选项都被视为拼错的调用。一个只被两个子命令消费的父级标志,恰恰就是适配器在其他所有地方都拒绝的泄漏选项形态。
**在 `parseDshArgs` 内部读取 `process.env`。** 拒绝:本仓库在进程边界做验证,并保持类型化接缝的纯粹性;否则测试必须在每个用例前后修改并恢复 `process.env`
**接受任何非空的 `DSH_EXPERIMENTAL`。** 拒绝:遥测开关作为隐私控制倾向于误关而非误开,但实验性门槛是一种确认——`DSH_EXPERIMENTAL=0` 绝不能启用它所指名的命令。
## Consequences
日常调用缩短为 `dsh meta --experimental``dsh upgrade --experimental`;在环境中设置了 `DSH_EXPERIMENTAL=1` 的开发者可以直接使用 `dsh meta`/`dsh upgrade``dsh --help` 将这两个命令标注为 `(experimental)`。在命令稳定之前,门槛的代价是一个额外的标志或环境变量;稳定时删除门槛即可,名字已是最终形态。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3 2026-07-31-permission-default-for-new-sessions.md: ffa4c8a07bdd08ca52edbc14fe10372ad76e8cf8
2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177 2026-07-31-permission-default-for-new-sessions.zh.md: 5fc42724754acc1653ed93b48644794a38f52ba7

View File

@@ -22,7 +22,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c
Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet. The assembled Web snapshot contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `workspace-write` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
## Alternatives considered ## Alternatives considered

View File

@@ -22,7 +22,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入
在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset` 在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`
组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
## 曾考虑的替代方案 ## 曾考虑的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md
2026-07-31-web-default-search.md: d9616c27410bb5be9b385a9aaa56c22f6054eeb1 2026-07-31-web-default-search.md: 121a1dff5fffd4223eefcc7475fff874276658aa
2026-07-31-web-default-search.zh.md: 27cd330427669a78c03b939c737b37f79fd7965a 2026-07-31-web-default-search.zh.md: ac98f4806413cb6050a08354a553942437866fe1

View File

@@ -16,7 +16,7 @@ DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the off
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams. Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped deployment already defaults to `danger-full-access`; a future restricted-network product stance must add a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls. The default mount does not create a Web-specific permission policy. `web_search` executes outside the bash/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. It does not mount `web_fetch` or a local fetch provider, so the default does not grant model-selected arbitrary URL retrieval. The shipped `workspace-write` default governs file mutations only; a restricted-network product stance requires a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
## Alternatives considered ## Alternatives considered

View File

@@ -16,7 +16,7 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent智能体会话追加仅用于日志的 LLM大语言模型请求事件 `web/deepseek-search-llm-request`其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。 搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent智能体会话追加仅用于日志的 LLM大语言模型请求事件 `web/deepseek-search-llm-request`其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash文件系统沙箱及审批预设之外执行并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付部署的默认值本就是 `danger-full-access`;未来如果产品采取受限网络策略,必须添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。 默认挂载不会创建 Web 专用权限策略。`web_search` 在 bash文件系统沙箱及审批预设之外执行并遵循 `dsh-tool-web` 的现有契约。组合不挂载 `web_fetch` 或本地抓取提供方,因此默认配置不会允许模型自行选择任意 URL 进行抓取。已交付`workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
## 考虑过的替代方案 ## 考虑过的替代方案

View File

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

View File

@@ -0,0 +1,35 @@
# Agent Note: Workspace-write defaults for shipped surfaces
Status: implemented
English | [中文](2026-07-31-workspace-write-surface-default.zh.md)
## Problem
The shipped terminal and browser surfaces exposed the same coding tools under different unconfined compositions. Web mounted the sandbox and permission services but selected `danger-full-access`; the TUI mounted the unrestricted local bash and filesystem providers directly. A fresh coding session could therefore mutate any path its same-UID process could reach before the user deliberately chose that authority.
## Decision
[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam.
A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there.
The mode governs file effects only. Sandboxed bash and filesystem mutations admit the session workspace and platform temporary roots; reads, network access, and process visibility remain outside this policy. If no platform runner can enforce a confined bash call, execution fails closed instead of falling through to an unrestricted command.
## Testing
The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads the persisted first request, and asserts both the `sandbox_permissions`/`justification` bash schema and the initial workspace-write event triplet. The shipped-Web composition smoke asserts the same policy, approval, and Permission defaults. The assembled browser Settings snapshot opens on Workspace Write, preserves an existing workspace-write session while changing the future default, and still proves the confirmed Full-access path.
## Alternatives considered
**Keep the sandbox stack in `web.cordis.yml` and duplicate it into `tui.cordis.yml`.** Rejected because the plugin identities, presets, fallback, and executor swap are identical. Two copies would make a security default depend on keeping surface overlays synchronized; the shared base is their one owner.
**Leave the TUI unrestricted and change only the browser fallback.** Rejected because it preserves the unexplained surface difference and leaves a fresh terminal session with the authority this decision removes.
**Add a terminal approval dialog in the same change.** Rejected as a separate interaction and lifecycle decision. The TUI has no `approval/request` answerer, so a one-shot automatic escalation currently settles unavailable and fails closed; a user who needs wider authority can deliberately select another preset through `/permission`.
## Consequences
Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. Stored user defaults and logged session permissions are not rewritten.
The browser-backed headless entry inherits the Web composition and therefore the same default. The TUI's missing approval answerer is a deliberate limitation of this change: automatic wider retries fail closed there instead of displaying a permission question.

View File

@@ -0,0 +1,35 @@
# Agent Note: 已交付界面的 workspace-write 默认值
Status: implemented
[English](2026-07-31-workspace-write-surface-default.md) | 中文
## 问题
已交付的终端和浏览器界面在两套不同的无约束组合下暴露相同的编码工具。Web 挂载了沙箱与权限服务,却选择 `danger-full-access`TUI 则直接挂载不受限的本地 bash 与文件系统提供方。因此,在用户主动选择这类权限之前,全新的编码会话就能修改其同 UID 进程可达的任意路径。
## 决策
[`base.cordis.yml`](../../../../apps/cli/config/base.cordis.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local``dsh-sandbox-policy``dsh-bash-sandbox``dsh-fs-sandbox``dsh-user-approval``dsh-permission`。组合回退值为 `workspace-write` preset其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。
真正的新会话会在执行前固定 `permission/preset: workspace-write``sandbox/mode: workspace-write``approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝方式关闭,不会退回不受限命令。
## 测试
已交付 TUI 的无密钥伪终端冒烟测试会启动真实 Loader 树,读取已持久化的首个请求,并断言 bash schema 中的 `sandbox_permissions``justification`,以及初始的 workspace-write 事件三元组。已交付 Web 组合的冒烟测试断言相同的策略、审批与 Permission 默认值。组装后的浏览器 Settings 快照打开时选中 Workspace Write在更改后续会话默认值时保持现有 `workspace-write` 会话不变,并仍然验证经确认后选择 Full access 的路径。
## 曾考虑的替代方案
**将沙箱栈留在 `web.cordis.yml`,并在 `tui.cordis.yml` 中复制一份。** 不予采纳因为插件标识、preset、回退值与执行器替换完全相同。两份副本会让安全默认值依赖两个界面覆盖层持续同步共享 base 才是它们的唯一归属。
**保留不受限的 TUI只更改浏览器回退值。** 不予采纳,因为这会保留无法解释的界面差异,并让全新的终端会话继续拥有本决策要移除的权限。
**在同一次变更中添加终端审批对话框。** 不予采纳因为这是另一个交互与生命周期决策。TUI 没有 `approval/request` 应答者,因此一次性自动升权当前会落定为不可用并以拒绝方式关闭;需要更宽权限的用户可以通过 `/permission` 主动选择其他 preset。
## 后果
全新的会话无需额外提示即可修改当前工作区与临时根目录尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。
由浏览器支撑的无头入口继承 Web 组合因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝方式关闭,而不会显示权限询问。

View File

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

View File

@@ -0,0 +1,51 @@
# Agent Note: the installer adopts an existing checkout into the managed layout
Status: implemented
English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md)
## Problem
`scripts/install.sh` produced two incompatible install shapes. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/<timestamp>`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md).
The direct link is a terminal state. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this shape as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever.
## Decision
In-repo mode still never clones and never modifies the working tree, but it now **adopts** the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install.
The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`.
The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's `.git` file names the path, and `git worktree list` in the clone enumerates every worktree depending on it.
Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree.
Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every `mktemp` path that way through `/var` -> `private/var`. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect recurred twice more during review, both times as one side of a comparison left unresolved: a curl install's `REPO_ROOT`, and the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+.
Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone.
## Alternatives considered
**Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …/<clone>/.git/worktrees/<name>`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative.
**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to be a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing.
**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The divergent shape was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must reason about — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a shape nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains the escape hatch for installing a separate tree.
**Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead.
**Put an adopted clone's staging worktrees beside the clone** (`~/src/staging-*`) rather than in `~/.dsh/source`. Rejected: `current` and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent.
## Consequences
One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable shape. In-repo runs still never mutate the working tree.
The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again.
A container adopting an outside clone is also no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and git's own worktree records are what diagnose it.
## Testing
`scripts/install.sh` has no automated test, and this change does not add one: the user directed that `install.spec.ts` be left out of scope. That is a known gap on a shipped user-facing path, and the `/var` resolution defect above is exactly the class of bug a test would have caught first. The standing [`FIXME(install-ts)`](../../../../scripts/install.sh) asking for this workflow to move into a tested TypeScript entrypoint is correspondingly more pressing.
Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, which is the regression that caught the unresolved-`REPO_ROOT` defect. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file.

View File

@@ -0,0 +1,51 @@
# Agent Note: 安装器把已有检出接管进受管布局
Status: implemented
[English](2026-07-31-installer-adopts-existing-checkout.md) | 中文
## Problem
`scripts/install.sh`会产生两种互不兼容的安装形态。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`
这种直接链接是一种终态。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级检出一旦移动PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级契约禁止作为启动器目标的情形。升级技能早已把这种形态描述为需要一次性迁移的旧式安装,于是两种布局在安装时就已分叉,并且要到很久以后才会被调和——甚至永远不会。
## Decision
检出内模式仍然绝不克隆、绝不修改工作树,但现在它会无条件地把该检出**接管**进受管布局。不存在退出选项:一套布局服务于所有安装。
容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。
安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。
接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。
所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行且每个参与比较的值都在赋值时解析而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过`/var` -> `private/var`使每个`mktemp`路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。同一缺陷在评审过程中又出现了两次,两次都是比较的一侧未经解析:一次是 curl 安装的`REPO_ROOT`,一次是与之比较的容器路径。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。
在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级契约。
## Alternatives considered
**把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。
**把检出自身提升为`current`的目标。** 已否决:升级契约要求`current`必须是位于 staging 分支上的干净 staging worktree绝不能是 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。
**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。分叉的形态本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动需要推敲的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的形态而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍是安装另一棵树的退路。
**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该契约。
**把被接管克隆的 staging worktree 放在该克隆旁边**`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。
## Consequences
现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的形态。检出内运行仍然绝不改动工作树。
代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`
此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性而非本设计带来的性质——被否决的符号链接方案只是掩盖它而非修复它——诊断依据则是 git 自身的 worktree 记录。
## Testing
`scripts/install.sh`没有自动化测试,本次变更也未添加:用户明确要求把`install.spec.ts`排除在范围之外。这是一条已交付的、面向用户的安装路径上的已知缺口,而上文那个`/var`解析缺陷,恰恰属于测试本应最先捕获的那类 bug。相应地要求把这套流程迁移到有测试覆盖的 TypeScript 入口的既有[`FIXME(install-ts)`](../../../../scripts/install.sh)也变得更为紧迫。
验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——正是这项回归测试捕获了`REPO_ROOT`未解析的缺陷。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write README.md # pnpm run verify-translation-pairing --write README.md
README.md: fb956dce51838438fb508db7ea9ebdf9e0b3a50b README.md: 8ecd0928ee630eeca1cb8ce8b9c59d19f6984969
README.zh.md: aa80b744465d7d253a54fffeead7262a1fdf69eb README.zh.md: 9ffb3b3086415550df4a0f776c7b91c94122dd97

View File

@@ -24,7 +24,7 @@ cd deepseek-harness
scripts/install.sh scripts/install.sh
``` ```
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key. The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, then lets you launch the Web UI or TUI. Choosing Web UI builds the required repository artifacts first.
The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options. The installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.
@@ -32,14 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
### Web UI ### Web UI
For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI: For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:
```sh ```sh
(cd ~/.dsh/source/current && pnpm run build) (cd ~/.dsh/source/current && pnpm run build)
dsh web dsh web
``` ```
The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
### TUI ### TUI

View File

@@ -24,7 +24,7 @@ cd deepseek-harness
scripts/install.sh scripts/install.sh
``` ```
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。 安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,随后让你选择启动 Web UI 或 TUI。选择 Web UI 时,安装器会先构建所需的仓库产物
安装器会把所有检出都放在 `~/.dsh/source`master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`因此升级只需重指一个符号链接PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。 安装器会把所有检出都放在 `~/.dsh/source`master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`因此升级只需重指一个符号链接PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。
@@ -32,14 +32,14 @@ scripts/install.sh
### Web UI ### Web UI
推荐在本地使用 Web UI安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI 推荐在本地使用 Web UI安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行
```sh ```sh
(cd ~/.dsh/source/current && pnpm run build) (cd ~/.dsh/source/current && pnpm run build)
dsh web dsh web
``` ```
完整构建会生成库与客户端 bundle以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE``DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE``DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
### TUI ### TUI

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md # pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 7ef24dc6290af5aafe3eb53609b090fcbfb83ce8 README.md: 774bb08d2e923e15db23b3980eff7759b45fc146
README.zh.md: b19d4979351940e925db3b7a616a5c87689770a1 README.zh.md: de096659679df3d73f9aa76c9563b31c8ecb3cb6

View File

@@ -3,26 +3,28 @@
English | [中文](README.zh.md) English | [中文](README.zh.md)
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`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `experimental-upgrade`, `web`, `experimental-meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. 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`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is a guided-session entry, and whose `web` subcommand is the browser UI. `meta` and `upgrade` are experimental: each runs only with its `--experimental` flag or with `DSH_EXPERIMENTAL=1` in the environment, and fails loud (stderr, exit 1) otherwise. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
The TUI surface: The TUI surface:
- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree; - boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree;
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-meta` is the sole exception, below); - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`. - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context. - presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible. - registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
`dsh experimental-meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session. `dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh experimental-upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection. `dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. The command takes no options beyond the experimental gate `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`. `dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shared composition defaults new TUI, Web, and headless sessions to the `workspace-write` permission preset (`workspace-write` file mode plus `ask` approval policy). Sandbox-enforced bash and filesystem mutations may write only under the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. The browser answers one-shot approval requests and exposes the Access picker; the TUI exposes `/permission`, but has no approval-request answerer, so an automatic wider retry there fails closed until the user deliberately changes the session preset. `DSH_PERMISSION_MODE` changes the process fallback, while a stored General-settings Permission value applies to later sessions without changing an open one.
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md). Every surface also registers `web_search` and only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).

View File

@@ -3,26 +3,28 @@
[English](README.md) | 中文 [English](README.md) | 中文
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``--dump-config``--dump-default-config``experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。凡与默认界面不共享任何选项的子命令`experimental-upgrade``web``experimental-meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI无头界面`--config``-p`/`--prompt``--resume``--dump-config``--dump-default-config``meta` 子命令是以本 checkout 为 workspace 的同一个 TUI`upgrade` 子命令是引导会话入口,`web` 子命令则是浏览器 UI。`meta``upgrade` 是实验性命令:只有带上各自的 `--experimental` 标志或在环境中设置 `DSH_EXPERIMENTAL=1` 才会运行否则明确报错stderr退出码 1`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错stderr退出码 1而不会被错路由。凡与默认界面不共享任何选项的子命令`upgrade``web``meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web``--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面: TUI 界面:
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml``tui.cordis.yml``--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动; - 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml``tui.cordis.yml``--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文); -**调用目录** 视为 workspace会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env` - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
-`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。 -`DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。 - 注册裸 `/compact`agent 空闲时即使未达到自动压力也会摘要有效的较早历史该命令拒绝参数并只在独立替换标记对持久化后报告成功。压缩compaction期间提交的提示词保留其队列身份并在该检查点之后启动注入的上下文仍保持可见。
`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>` `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
`dsh experimental-upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)TUI 在聊天就绪后自动调用它。该命令不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)TUI 在聊天就绪后自动调用它。该命令除实验性门槛外不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
`dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config` `dsh --dump-config``dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include``applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr与 Loader 启动时的警告一致。由启动器持有的启动上下文值会话身份、CLI 标志补丁是每次调用的事实位于配置树之外不会出现。dump 标志会拒绝仅用于启动的标志(`-p``--resume``--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。 Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle`pnpm run build && pnpm run build:web`)。
共享组合把新建 TUI、Web 和无头会话的权限默认设为 `workspace-write` preset`workspace-write` 文件模式加 `ask` 审批策略)。由沙箱强制约束的 bash 与文件系统修改只能写入会话工作区和平台临时根目录;读取、网络访问和进程可见性不受该策略约束。浏览器可以应答一次性审批请求,并提供 Access 选择器TUI 提供 `/permission`,但没有审批请求应答者,因此自动请求更宽权限的重试会以拒绝方式关闭,直到用户主动更改会话 preset。`DSH_PERMISSION_MODE` 会更改进程回退值,而「通用」设置中已存储的「权限」值只适用于之后的会话,不会更改已打开的会话。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL``OPENAI_API_KEY` / `OPENAI_BASE_URL``ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`
每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。 每个界面也都只注册 `web_search` 这一个 Web 工具。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。

View File

@@ -42,8 +42,16 @@ flowchart LR
cfg --> plugin_tui_telemetry_otel cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"] plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local<br/>@deepseek-ai/dsh-bash-local"] plugin_tui_sandbox["sandbox<br/>@deepseek-ai/dsh-sandbox-local"]
cfg --> plugin_tui_bash_local cfg --> plugin_tui_sandbox
plugin_tui_sandbox_policy["sandbox-policy<br/>@deepseek-ai/dsh-sandbox-policy"]
cfg --> plugin_tui_sandbox_policy
plugin_tui_bash_sandbox["bash-sandbox<br/>@deepseek-ai/dsh-bash-sandbox"]
cfg --> plugin_tui_bash_sandbox
plugin_tui_approval["approval<br/>@deepseek-ai/dsh-user-approval"]
cfg --> plugin_tui_approval
plugin_tui_permission["permission<br/>@deepseek-ai/dsh-permission"]
cfg --> plugin_tui_permission
plugin_tui_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"] plugin_tui_tool_bash["tool-bash<br/>@deepseek-ai/dsh-tool-bash"]
cfg --> plugin_tui_tool_bash cfg --> plugin_tui_tool_bash
plugin_tui_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"] plugin_tui_tool_tasks["tool-tasks<br/>@deepseek-ai/dsh-tool-tasks"]
@@ -126,8 +134,8 @@ flowchart LR
cfg --> plugin_tui_system_prompt cfg --> plugin_tui_system_prompt
plugin_tui_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"] plugin_tui_agent_loop["agent-loop<br/>@deepseek-ai/dsh-agent-loop"]
cfg --> plugin_tui_agent_loop cfg --> plugin_tui_agent_loop
plugin_tui_fs_local["fs-local<br/>@deepseek-ai/dsh-fs-local"] plugin_tui_fs_sandbox["fs-sandbox<br/>@deepseek-ai/dsh-fs-sandbox"]
cfg --> plugin_tui_fs_local cfg --> plugin_tui_fs_sandbox
plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"] plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_tui_llm_deepseek cfg --> plugin_tui_llm_deepseek
``` ```
@@ -151,7 +159,11 @@ flowchart LR
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` | | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
| `sandbox-policy` | `@deepseek-ai/dsh-sandbox-policy` |
| `bash-sandbox` | `@deepseek-ai/dsh-bash-sandbox` |
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` | | `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | | `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` |
| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` |
@@ -193,7 +205,7 @@ flowchart LR
| `tools` | `@deepseek-ai/dsh-tools` | | `tools` | `@deepseek-ai/dsh-tools` |
| `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` |
| `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` |
| `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml). Source config: [`apps/cli/config/base.cordis.yml`](config/base.cordis.yml).

View File

@@ -131,11 +131,42 @@
- id: subprocess - id: subprocess
name: '@deepseek-ai/dsh-subprocess-local' name: '@deepseek-ai/dsh-subprocess-local'
- id: bash-local # Every shipped product surface starts with the same file-effect boundary.
name: '@deepseek-ai/dsh-bash-local' # The environment remains an explicit deployment override; otherwise fresh
# sessions pin workspace-write + ask through the permission service below.
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
config: config:
timeoutMs: 60000 timeoutMs: 60000
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: tool-bash - id: tool-bash
name: '@deepseek-ai/dsh-tool-bash' name: '@deepseek-ai/dsh-tool-bash'
@@ -339,10 +370,10 @@
config: config:
agents: [] agents: []
# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the # The sandboxed filesystem provider. `cwd` defaults to `process.cwd()`; the TUI
# TUI states it explicitly because that value is also the session workspace. # states it explicitly because that value is also the session workspace.
- id: fs-local - id: fs-sandbox
name: '@deepseek-ai/dsh-fs-local' name: '@deepseek-ai/dsh-fs-sandbox'
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per # The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
# request from the `llm-deepseek:` settings section over this entry, with the # request from the `llm-deepseek:` settings section over this entry, with the

View File

@@ -46,7 +46,7 @@
reasoningEffort: max reasoningEffort: max
# This single-session app resolves relative paths from the process cwd. # This single-session app resolves relative paths from the process cwd.
- id: fs-local - id: fs-sandbox
config: config:
cwd: !!js process.cwd() cwd: !!js process.cwd()

View File

@@ -36,50 +36,6 @@
apiKey: !!js process.env.DEEPSEEK_API_KEY apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL baseURL: !!js process.env.DEEPSEEK_BASE_URL
# The web surface replaces the unrestricted local executors with the shared
# sandbox policy. Its default preserves the previous unrestricted behavior;
# DSH_PERMISSION_MODE and the browser permission picker can confine a session.
- insert:
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
- id: fs-sandbox
name: '@deepseek-ai/dsh-fs-sandbox'
- id: bash-local
disabled: true
- id: fs-local
disabled: true
# ── web-only host rows, the transport layer, and the browser roster ───────── # ── web-only host rows, the transport layer, and the browser roster ─────────
# `dshClient` rows are the browser roster the modules node half scans into # `dshClient` rows are the browser roster the modules node half scans into

View File

@@ -324,7 +324,7 @@ export class AppCLIEntry {
try { try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html') return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch { } catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first') throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
} }
} }
} }

View File

@@ -3,7 +3,9 @@
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant * parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no * and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; * subcommand) is the TUI/headless surface with option-only flags;
* `experimental-meta` and `web` are real subcommands. Commander owns * `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
* (`meta`, `upgrade`) run only under the `--experimental` flag or
* `DSH_EXPERIMENTAL=1`. Commander owns
* `--help`/`--version` and parse * `--help`/`--version` and parse
* errors — it prints and exits at the point of failure (a domain failure routes through * errors — it prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode. * `command.error`), so this returns only a resolved mode.
@@ -46,16 +48,17 @@ interface HeadlessInvocation {
prompt: string prompt: string
} }
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */ /** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
interface MetaInvocation { interface MetaInvocation {
mode: 'meta' mode: 'meta'
} }
/** /**
* Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn * Guided fresh-session entry: `dsh upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It always mints a * with the `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options — `--resume`, * fresh session in the invoking directory and takes no options beyond the
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. * experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
*/ */
interface SkillSessionInvocation { interface SkillSessionInvocation {
mode: 'upgrade' mode: 'upgrade'
@@ -154,9 +157,11 @@ function resolveWeb(options: WebOptions): WebInvocation {
* TUI/headless surface; `web` is a subcommand. * TUI/headless surface; `web` is a subcommand.
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
* @param version - the version string `--version` prints; read from this app's package.json. * @param version - the version string `--version` prints; read from this app's package.json.
* @param experimentalEnv - whether the environment opts into experimental
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
* @returns the resolved invocation (only reached on a valid, non-help invocation). * @returns the resolved invocation (only reached on a valid, non-help invocation).
*/ */
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
let resolved: DshInvocation | undefined let resolved: DshInvocation | undefined
const program = new Command() const program = new Command()
.name('dsh') .name('dsh')
@@ -248,16 +253,27 @@ Examples:
} }
} }
// `meta` and `upgrade` are experimental: each runs only under its own
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
// and fails loud otherwise so the gate is never silently skipped.
const requireExperimental = (command: string, flag: boolean | undefined): void => {
if (flag !== true && !experimentalEnv) {
program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}
// Registration order is the rendered help order, so daily use comes first // Registration order is the rendered help order, so daily use comes first
// and the harness-development surfaces (`web --dev`, `experimental-meta`) // and the harness-development surfaces (`web --dev`, `meta`)
// come last. `experimental-upgrade` is a guided fresh-session entry: it // come last. `upgrade` is a guided fresh-session entry: beyond the
// takes no options and always mints a fresh session, so nothing is left to // experimental gate it takes no options and always mints a fresh session,
// carry. // so nothing is left to carry.
program program
.command('experimental-upgrade') .command('upgrade')
.description('update this dsh installation to the latest version') .description('update this dsh installation to the latest version (experimental)')
.action(() => { .option('--experimental', 'acknowledge this subcommand is experimental')
rejectParentOptions('experimental-upgrade') .action((options: { experimental?: boolean }) => {
rejectParentOptions('upgrade')
requireExperimental('upgrade', options.experimental)
resolved = { mode: 'upgrade' } resolved = { mode: 'upgrade' }
}) })
@@ -285,10 +301,12 @@ Examples:
}) })
program program
.command('experimental-meta') .command('meta')
.description('work on the dsh source that runs this command, from any directory') .description('work on the dsh source that runs this command, from any directory (experimental)')
.action(() => { .option('--experimental', 'acknowledge this subcommand is experimental')
rejectParentOptions('experimental-meta') .action((options: { experimental?: boolean }) => {
rejectParentOptions('meta')
requireExperimental('meta', options.experimental)
resolved = { mode: 'meta' } resolved = { mode: 'meta' }
}) })

View File

@@ -25,7 +25,8 @@ function readVersion(): string {
} }
loadEnv('dsh') loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion()) // The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
switch (invocation.mode) { switch (invocation.mode) {
case 'web': { case 'web': {

View File

@@ -8,8 +8,8 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage * from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every * is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory. * workspace, and an in-place resume enters the selected session's own directory.
* `dsh experimental-meta` is the one exception — it makes this harness * `dsh meta` is the one exception — it makes this harness
* checkout the workspace. `dsh experimental-upgrade` is a fresh session whose * checkout the workspace. `dsh upgrade` is a fresh session whose
* first turn auto-invokes a bundled skill. After boot, the agent's system * first turn auto-invokes a bundled skill. After boot, the agent's system
* prompt is told the path to this harness checkout so it can find its own * prompt is told the path to this harness checkout so it can find its own
* source. * source.
@@ -71,7 +71,7 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH // from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives. // symlink, an arbitrary cwd). The agent is told where its own source lives.
/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */ /** The harness checkout used as the `dsh meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
@@ -88,9 +88,9 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
* {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session
* and an overlay replacing the agent row cannot drop it. * and an overlay replacing the agent row cannot drop it.
* @param workspace - a directory to make the workspace instead of the invoking * @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it. * one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's * @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by `dsh experimental-upgrade` and * first turn, or `undefined`. Set only by `dsh upgrade` and
* ignored on a resume, so it never re-fires; reaches the app through * ignored on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}. * {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
@@ -140,7 +140,7 @@ export async function runTui(
const entry = process.argv[1] const entry = process.argv[1]
const execve = process.execve?.bind(process) const execve = process.execve?.bind(process)
const app: { current?: Context } = {} const app: { current?: Context } = {}
// Resume always enters the default surface because experimental-meta rejects // Resume always enters the default surface because meta rejects
// parent options, including `--resume`. The resumed session already persists // parent options, including `--resume`. The resumed session already persists
// its cwd. // its cwd.
const resumeArgs = (sessionId: string): string[] => [ const resumeArgs = (sessionId: string): string[] => [

View File

@@ -1,18 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts' import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') const parse = (argv: string[], experimentalEnv = false) => parseDshArgs(argv, '1.2.3', experimentalEnv)
/** /**
* `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets
* Commander print to the real streams; capture the exit code and mute output. * Commander print to the real streams; capture the exit code and mute output.
*/ */
function exitCode(argv: string[]): number { function exitCode(argv: string[], experimentalEnv = false): number {
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(process.stderr, 'write').mockReturnValue(true) vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try { try {
parse(argv) parse(argv, experimentalEnv)
throw new Error(`expected ${JSON.stringify(argv)} to exit`) throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch { } catch {
return exit.mock.calls.at(-1)?.[0] as number return exit.mock.calls.at(-1)?.[0] as number
@@ -30,7 +30,9 @@ describe('parseDshArgs', () => {
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' })
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' }) // Experimental subcommands run under the per-invocation flag or the env opt-in.
expect(parse(['meta', '--experimental'])).toEqual({ mode: 'meta' })
expect(parse(['meta'], true)).toEqual({ mode: 'meta' })
// Bare `web` carries no host/port: the shipped Web overlay owns the default. // Bare `web` carries no host/port: the shipped Web overlay owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' }) expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
@@ -39,7 +41,8 @@ describe('parseDshArgs', () => {
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) 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' }) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
// Guided fresh-session entries carry nothing: bare mode discriminant only. // Guided fresh-session entries carry nothing: bare mode discriminant only.
expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' }) expect(parse(['upgrade', '--experimental'])).toEqual({ mode: 'upgrade' })
expect(parse(['upgrade'], true)).toEqual({ mode: 'upgrade' })
// --trusted-host is variadic and repeatable; authorities pass through unvalidated. // --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'])) 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'] }) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
@@ -64,8 +67,8 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
// A leaked dump flag on a subcommand that has none is a mistyped invocation. // A leaked dump flag on a subcommand that has none is a mistyped invocation.
expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1) expect(exitCode(['meta', '--experimental', '--dump-config'])).toBe(1)
expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1) expect(exitCode(['upgrade', '--experimental', '--dump-config'])).toBe(1)
}) })
it('exits nonzero instead of silently starting fresh or dropping inputs', () => { it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
@@ -88,20 +91,32 @@ describe('parseDshArgs', () => {
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
// Same rule for each subcommand that shares no option with the default // Same rule for each subcommand that shares no option with the default
// surface, so a leaked flag is a typo, not something to ignore. // surface, so a leaked flag is a typo, not something to ignore.
// `experimental-meta` fixes its own config tree and always starts fresh, // `meta` fixes its own config tree and always starts fresh,
// so every default-surface option is rejected. // so every default-surface option is rejected.
expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1) expect(exitCode(['meta', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '--experimental', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1) expect(exitCode(['meta', '--experimental', '-p', 'task'])).toBe(1)
// `experimental-upgrade` takes no options: any leaked default-surface flag // `upgrade` takes no options beyond the gate: any leaked default-surface
// is a mistyped invocation, not a silently-dropped input. // flag is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--experimental', '--resume', 's'])).toBe(1)
expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1) expect(exitCode(['upgrade', '--experimental', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1) expect(exitCode(['-p', 'task', 'upgrade', '--experimental'])).toBe(1)
// The pre-release command names have no compatibility aliases. // The pre-release command names have no compatibility aliases.
expect(exitCode(['experimental-meta'])).toBe(1)
expect(exitCode(['experimental-upgrade'])).toBe(1)
})
it('gates experimental subcommands behind --experimental or the env opt-in', () => {
// Bare `meta`/`upgrade` without either opt-in must fail loud, not run.
expect(exitCode(['meta'])).toBe(1) expect(exitCode(['meta'])).toBe(1)
expect(exitCode(['upgrade'])).toBe(1) expect(exitCode(['upgrade'])).toBe(1)
// A leaked default-surface flag stays a typo even when the gate is passed
// by the environment alone.
expect(exitCode(['meta', '--resume', 's'], true)).toBe(1)
// The flag and the env opt-in may coexist.
expect(parse(['meta', '--experimental'], true)).toEqual({ mode: 'meta' })
expect(parse(['upgrade', '--experimental'], true)).toEqual({ mode: 'upgrade' })
}) })
it('exits 0 for --help (disclosing web) and --version', () => { it('exits 0 for --help (disclosing web) and --version', () => {

View File

@@ -54,6 +54,31 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(stdout).toBe('') expect(stdout).toBe('')
}, 30_000) }, 30_000)
describe('experimental subcommand gate', () => {
// The gate has two halves: a per-invocation --experimental flag parsed by
// Commander and an env opt-in read by bin.ts as exactly '1'. Passing the
// gate is proven by reaching the NEXT failure — the TUI's piped-stdio
// refusal — instead of the gate diagnostic.
it('rejects bare `meta`/`upgrade` LOUD, naming both opt-ins', async () => {
for (const command of ['meta', 'upgrade']) {
const { code, stderr } = await runBuiltBin([command], { DSH_EXPERIMENTAL: '' })
expect(code).toBe(1)
expect(stderr).toContain(`${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}, 30_000)
it('admits --experimental and DSH_EXPERIMENTAL=1, but not other env values', async () => {
const flagged = await runBuiltBin(['meta', '--experimental'], { DSH_EXPERIMENTAL: '' })
expect(flagged.stderr).toContain('requires stdin and stdout to be interactive TTYs')
const env = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '1' })
expect(env.stderr).toContain('requires stdin and stdout to be interactive TTYs')
// The env opt-in is exact: '0' (or any other value) does not enable.
const zero = await runBuiltBin(['meta'], { DSH_EXPERIMENTAL: '0' })
expect(zero.code).toBe(1)
expect(zero.stderr).toContain('meta is experimental')
}, 30_000)
})
describe('dsh --dump-config', () => { describe('dsh --dump-config', () => {
let home: string let home: string
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) }) beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })

View File

@@ -0,0 +1,155 @@
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { execa } from 'execa'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
const fixtures: string[] = []
const PTY_DRIVER = String.raw`
import errno, json, os, pty, select, signal, sys, time
script, cwd, env_json, actions_json = sys.argv[1:]
env = os.environ.copy()
env.update(json.loads(env_json))
actions = json.loads(actions_json)
pid, fd = pty.fork()
if pid == 0:
os.chdir(cwd)
os.execvpe("sh", ["sh", script], env)
output = bytearray()
action_index = 0
deadline = time.monotonic() + 15
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([fd], [], [], 0.05)
if ready:
try:
chunk = os.read(fd, 65536)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
output.extend(chunk)
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
os.write(fd, actions[action_index]["send"].encode())
action_index += 1
waited, candidate = os.waitpid(pid, os.WNOHANG)
if waited == pid:
status = candidate
break
if status is None:
os.kill(pid, signal.SIGKILL)
_, status = os.waitpid(pid, 0)
sys.stdout.buffer.write(output)
if action_index != len(actions):
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
sys.exit(124)
sys.exit(os.waitstatus_to_exitcode(status))
`
interface Action {
readonly waitFor: string
readonly send: string
}
interface Fixture {
readonly binDirectory: string
readonly launchLog: string
readonly pnpmLog: string
readonly root: string
readonly script: string
}
afterEach(async () => {
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
})
function executable(path: string, content: string): void {
writeFileSync(path, content)
chmodSync(path, 0o755)
}
async function createFixture(): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
fixtures.push(root)
const checkoutDirectory = join(root, 'checkout')
const scriptsDirectory = join(checkoutDirectory, 'scripts')
const sourceBinDirectory = join(checkoutDirectory, 'bin')
const fakeBinDirectory = join(root, 'fake-bin')
const binDirectory = join(root, 'path-bin')
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
mkdirSync(directory, { recursive: true })
}
const script = join(scriptsDirectory, 'install.sh')
copyFileSync(installer, script)
const launchLog = join(root, 'launch.log')
const pnpmLog = join(root, 'pnpm.log')
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
`)
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
await execa('git', [
'-c', 'user.name=dsh-test',
'-c', 'user.email=dsh-test@example.invalid',
'commit', '-qm', 'fixture',
], { cwd: checkoutDirectory })
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
return { binDirectory, launchLog, pnpmLog, root, script }
}
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
const result = await execa('python3', [
'-c',
PTY_DRIVER,
fixture.script,
fixture.root,
JSON.stringify({
DSH_BIN_DIR: fixture.binDirectory,
DSH_HOME: join(fixture.root, 'home/.dsh'),
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
HOME: join(fixture.root, 'home'),
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
}),
JSON.stringify(actions),
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
expect(result.exitCode, result.stderr).toBe(0)
return result.stdout
}
describe.runIf(process.platform !== 'win32')('one-line installer interface choice', { timeout: 25_000 }, () => {
it('builds and launches the Web UI when the default choice is accepted', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: '\n' },
])
expect(output).toContain('launching Web UI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
})
it('rejects an unknown choice, then launches the TUI without building', async () => {
const fixture = await createFixture()
const output = await runInstaller(fixture, [
{ waitFor: 'Replace it?', send: '\n' },
{ waitFor: 'Choose an interface [1/2]:', send: 'terminal\n' },
{ waitFor: 'choose 1 for Web UI or 2 for TUI', send: '2\n' },
])
expect(output).toContain('launching TUI')
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\n')
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('\n')
})
})

View File

@@ -11,6 +11,7 @@ import { acknowledgeTuiFirstRunWelcome } from '../src/tui-onboarding/tui-first-r
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const PERMISSION_SUMMARY = 'current preset workspace-write (available: read-only, workspace-write, danger-full-access)'
// An overlay over the shipped tree, so the catalog under test is the one // An overlay over the shipped tree, so the catalog under test is the one
// `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model // `base.cordis.yml` + `tui.cordis.yml` assemble; the tail only swaps the model
// and redirects session artifacts. // and redirects session artifacts.
@@ -67,6 +68,8 @@ interface LoggedHeader {
names: string[] names: string[]
/** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */ /** `bash`'s assembled parameter properties; the escalation pair is present only under a confining executor. */
bashArguments: Record<string, unknown> bashArguments: Record<string, unknown>
/** Initial permission facts pinned by the shipped composition. */
permissionEvents: Array<[string, unknown]>
} }
/** /**
@@ -82,18 +85,22 @@ async function loggedHeader(cwd: string): Promise<LoggedHeader> {
// A single keyless run writes one session log. // A single keyless run writes one session log.
const logRelPath = entries.find(name => name.endsWith('.jsonl')) const logRelPath = entries.find(name => name.endsWith('.jsonl'))
if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) const events = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
for (const line of lines) { .map(line => JSON.parse(line) as SessionEvent)
const event = JSON.parse(line) as SessionEvent const header = events.find(event => event.type === 'request/header')
if (event.type !== 'request/header') continue if (header === undefined || header.type !== 'request/header') {
const tools = event.data.header.tools ?? [] throw new Error(`session log ${logRelPath} has no request/header event`)
const bash = tools.find(schema => schema.name === 'bash') }
return { const tools = header.data.header.tools ?? []
names: tools.map(schema => schema.name).sort(), const bash = tools.find(schema => schema.name === 'bash')
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {}, return {
} names: tools.map(schema => schema.name).sort(),
bashArguments: (bash?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties ?? {},
permissionEvents: events.flatMap(event =>
event.type === 'permission/preset' || event.type === 'sandbox/mode' || event.type === 'approval/policy'
? [[event.type, event.data] as [string, unknown]]
: []),
} }
throw new Error(`session log ${logRelPath} has no request/header event`)
} }
describe('shipped dsh composition (real Loader tree in a PTY)', () => { describe('shipped dsh composition (real Loader tree in a PTY)', () => {
@@ -110,19 +117,24 @@ describe('shipped dsh composition (real Loader tree in a PTY)', () => {
// Artifact CI builds and smokes concurrently on a contended runner. // Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
actions: [ actions: [
{ waitFor: COMPOSITION_SETTLED_MARKER, send: 'Describe the shipped composition.\r' }, { waitFor: COMPOSITION_SETTLED_MARKER, send: '/permission\r' },
{ waitFor: PERMISSION_SUMMARY, send: 'Describe the shipped composition.\r' },
{ waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' }, { waitFor: COMPOSITION_REPLY_TEXT, send: '/exit\r' },
], ],
inspect: async (cwd) => { observed = await loggedHeader(cwd) }, inspect: async (cwd) => { observed = await loggedHeader(cwd) },
}) })
expect(output).toContain(COMPOSITION_REPLY_TEXT) expect(output).toContain(COMPOSITION_REPLY_TEXT)
expect(output).toContain(PERMISSION_SUMMARY)
expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS) expect(observed?.names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TUI_TOOLS)
// The packaged ripgrep binary ships with the dependency, so the pair is a // The packaged ripgrep binary ships with the dependency, so the pair is a
// fixed roster member on every host. // fixed roster member on every host.
expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS) expect(observed?.names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
// The TUI mounts the unrestricted local executors, so `tool-bash` emits no expect(observed?.bashArguments).toHaveProperty('sandbox_permissions')
// escalation pair. Pinning its absence keeps a later sandbox change from expect(observed?.bashArguments).toHaveProperty('justification')
// arriving here unannounced. expect(observed?.permissionEvents).toEqual([
expect(Object.keys(observed?.bashArguments ?? {})).not.toContain('sandbox_permissions') ['permission/preset', { preset: 'workspace-write' }],
['sandbox/mode', { mode: 'workspace-write' }],
['approval/policy', { policy: 'ask' }],
])
}, LOADER_SMOKE_TEST_TIMEOUT_MS) }, LOADER_SMOKE_TEST_TIMEOUT_MS)
}) })

View File

@@ -0,0 +1,30 @@
{"type":"session","version":0,"id":"main-session","createdAt":1784606400000,"cwd":"{{cwd}}"}
{"type":"turn/start","seq":0,"time":1784606400000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":1784606400000,"data":{"content":[{"type":"text","text":"what's the workdir?"}],"source":{"kind":"user"},"role":"user","id":"3fdc2885-1bea-4c6c-b4af-dbd5af7594f8"},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784606400000,"data":{"title":"what's the workdir?","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784606400000,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":6,"time0":1784606400000,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," working"," directory","."," Let"," me"," check"," using"," p","wd","."]}}
{"type":"assistant/chunk","seq":23,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":24,"time0":1784606400000,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","args":["","{","\"","command","\"",": ","\"","p","wd","\"",", ","\"","description","\"",": ","\"","Print"," current"," working"," directory","\"","}"]}}
{"type":"assistant/chunk","seq":46,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."}}}}
{"type":"assistant/chunk","seq":47,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}}}
{"type":"assistant/chunk","seq":48,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}}}}
{"type":"assistant/chunk","seq":49,"time":1784606400000,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":50,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking about the current working directory. Let me check using pwd."},{"type":"tool-call","id":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"063a9245-32c3-4551-9ace-d43f10ed5582"},"usage":{"inputTokens":3332,"outputTokens":80,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"surfaceOp":"append"}
{"type":"tool/call","seq":51,"time":1784606400000,"data":{"turn":1,"step":1,"callId":"call_00_AqoWTncquNel5ZHsJHOo7491","name":"bash","arguments":"{\"command\": \"pwd\", \"description\": \"Print current working directory\"}"}}
{"type":"tool/result","seq":52,"time":1784606400000,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_AqoWTncquNel5ZHsJHOo7491"},"content":[{"type":"tool-result","toolCallId":"call_00_AqoWTncquNel5ZHsJHOo7491","content":[{"type":"text","text":"{{cwd}}\n"}],"isError":false}],"role":"user","id":"16086d3b-6dfa-4970-a06e-78561475af8c"}},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","seq":53,"time":1784606400000,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":54,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":55,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":56,"time0":1784606400000,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is"," `/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","`."]}}
{"type":"assistant/chunk","seq":83,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"text-chunks","seq0":84,"time0":1784606400000,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," current"," working"," directory"," is",":\n\n","```\n","/","private","/t","mp","/d","sh","-t","ui","-s","n","apshot","-source","-check","out","-work","dir","-","61","I","wn","K","\n","```\n\n","Note"," that"," the"," Deep","Se","ek"," Har","ness"," SDK"," checkout"," is"," at"," `/","opt","/d","sh","-source","`"," —"," that","'s"," a"," separate"," location"," used"," for"," inspecting"," or"," extending"," D","SH"," itself",","," while"," the"," working"," directory"," above"," is"," the"," current"," working"," directory"," for"," this"," session","."]}}
{"type":"assistant/chunk","seq":161,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current working directory is `{{cwd}}`."}}}}
{"type":"assistant/chunk","seq":162,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}}}}
{"type":"assistant/chunk","seq":163,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}}}}
{"type":"assistant/chunk","seq":164,"time":1784606400000,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":165,"time":1784606400000,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The current working directory is `{{cwd}}`."},{"type":"text","text":"The current working directory is:\n\n```\n{{cwd}}\n```\n\nNote that the DeepSeek Harness SDK checkout is at `/opt/dsh-source` — that's a separate location used for inspecting or extending DSH itself, while the working directory above is the current working directory for this session."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"76c18b85-00aa-45f6-a32f-a2bd75412470"},"usage":{"inputTokens":117,"outputTokens":105,"cacheReadTokens":3328,"reasoningTokens":27}},"sourceEventSeqs":[55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"}
{"type":"step/end","seq":166,"time":1784606400000,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":167,"time":1784606400000,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,67 @@
terminal 100x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "what's the workdir? — DSH TUI snapshot"
cursor hidden column=7 viewportRow=32 bufferRow=32
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
style 10-16 bold
1| " what's the workdir?"
style 1-19 dim
2| " main-session"
style 1-12 dim
3| <blank>
4| "You "
style 0-2 fg=bright-magenta bold underline
5| "what's the workdir? "
6| <blank>
7| "Assistant "
style 0-8 fg=bright-magenta bold underline
8| "Reasoning "
style 0-8 dim italic
9| "The user is asking about the current working directory. Let me check using pwd. "
style 0-78 dim italic
10| <blank>
11| "● Tool / bash / Print current working directory"
style 0-46 fg=green
12| "$ pwd "
style 0-4 dim
13| "/workspace/project "
style 0-17 dim
14| "[exit 0] "
style 0-7 dim
15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
16| <blank>
17| "Assistant "
style 0-8 fg=bright-magenta bold underline
18| "Reasoning "
style 0-8 dim italic
19| "The current working directory is /workspace/project. "
style 0-32 dim italic
style 33-84 fg=cyan
style 85-85 dim italic
20| "The current working directory is: "
21| " "
22| " "
23| " /workspace/project "
style 2-53 fg=cyan
24| " "
25| " "
26| "Note that the DeepSeek Harness SDK checkout is at /opt/dsh-source — that's a separate location used "
style 50-64 fg=cyan
27| "for inspecting or extending DSH itself, while the working directory above is the current working "
28| "directory for this session. "
29| "Model wait 0.0s · Completed 2026-07-21 12:00:00 "
style 0-46 dim
30| <blank>
31| "/workspace/project deepseek-v4-flash ↑3.4k ↓185 cache 49% 3% c"
style 0-51 fg=bright-magenta bold
style 54-70 dim
style 73-93 dim
style 96-99 dim
32| " dsh ◍ "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
33-35| <blank>

View File

@@ -738,11 +738,12 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).not.toContain('[exit code: 3]') expect(output).not.toContain('[exit code: 3]')
}, PTY_SMOKE_TEST_TIMEOUT_MS) }, PTY_SMOKE_TEST_TIMEOUT_MS)
it('tells the model its source path and offers the bundled maintenance skills', async () => { it('distinguishes its source path from the current workdir and offers the bundled maintenance skills', async () => {
// The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
// this test file sits an equal depth under the same root, so the same hop applies. // this test file sits an equal depth under the same root, so the same hop applies.
// The source-path line is a system-prompt section; the bundled skills reach the // The source-path line explicitly distinguishes that checkout from the current workdir;
// model through a durable user message, so each assertion targets its own field. // bundled skills reach the model through a durable user message, so each assertion
// targets its own field.
const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
let context: LoggedRequestContext = { system: '', skillCatalog: '' } let context: LoggedRequestContext = { system: '', skillCatalog: '' }
await smoke({ await smoke({
@@ -758,7 +759,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
], ],
inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
}) })
expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) expect(context.system).toContain(`The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot' import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot'
import type { Agent } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
@@ -59,6 +60,10 @@ interface Scenario {
leavePlanModeAfterFirstTurn?: boolean leavePlanModeAfterFirstTurn?: boolean
recorded: boolean recorded: boolean
seedWorkspace?: boolean seedWorkspace?: boolean
/** Add the launcher's model-visible DSH source checkout at this fixed path. */
harnessSourceRoot?: string
/** Replace the real `pwd` result with a portable fixed-length workspace path. */
normalizePwdResult?: boolean
/** /**
* Load the opt-in `todo_write` tool for this scenario. The shipped TUI * Load the opt-in `todo_write` tool for this scenario. The shipped TUI
* config omits it, so only the todo-plan scenario (the enabled-path proof) * config omits it, so only the todo-plan scenario (the enabled-path proof)
@@ -115,6 +120,14 @@ const SCENARIOS: Scenario[] = [
expectedTools: ['bash'], expectedTools: ['bash'],
recorded: true, recorded: true,
}, },
{
name: 'source-checkout-workdir',
composition: 'native',
expectedTools: ['bash'],
recorded: true,
harnessSourceRoot: '/opt/dsh-source',
normalizePwdResult: true,
},
{ {
name: 'parallel-file-reads', name: 'parallel-file-reads',
composition: 'native', composition: 'native',
@@ -251,6 +264,12 @@ function rawSessionLog(session: Session): string {
].join('\n') ].join('\n')
} }
async function materializeFixtureCwd(fixtureFile: string, cwd: string, replayRoot: string): Promise<string> {
const realized = join(replayRoot, basename(fixtureFile))
await writeFile(realized, (await readFile(fixtureFile, 'utf8')).split('{{cwd}}').join(cwd))
return realized
}
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string { function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
return snapshot return snapshot
.split(`/private${cwd}`).join('/workspace/project') .split(`/private${cwd}`).join('/workspace/project')
@@ -294,6 +313,7 @@ async function mountScenarioContext(
displayCwd: string, displayCwd: string,
fixtureFile: string, fixtureFile: string,
childFiles: string[], childFiles: string[],
replayRoot: string | undefined,
): Promise<Context> { ): Promise<Context> {
class SnapshotLocalFileSystem extends LocalFileSystem { class SnapshotLocalFileSystem extends LocalFileSystem {
override async resolve( override async resolve(
@@ -313,6 +333,7 @@ async function mountScenarioContext(
tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
skills: { local: { agentsHome: join(cwd, '.agents') } }, skills: { local: { agentsHome: join(cwd, '.agents') } },
}) })
if (scenario.harnessSourceRoot !== undefined) addHarnessSourceSection(ctx, scenario.harnessSourceRoot)
await ctx.plugin(TokenMeterService) await ctx.plugin(TokenMeterService)
if (scenario.manualCompact === true) { if (scenario.manualCompact === true) {
await ctx.plugin(DeferredSnapshotCompactService, { auto: false }) await ctx.plugin(DeferredSnapshotCompactService, { auto: false })
@@ -349,7 +370,12 @@ async function mountScenarioContext(
if (MODE === 'record' && scenario.recorded) { if (MODE === 'record' && scenario.recorded) {
await ctx.plugin(LlmDeepSeek) await ctx.plugin(LlmDeepSeek)
} else { } else {
installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS }) if (replayRoot === undefined) throw new Error('replay mode requires an isolated fixture directory')
// Recorded model text may name the generated cwd. Realize the portable token
// outside that cwd so tools see only the scenario workspace during replay.
const replayFile = await materializeFixtureCwd(fixtureFile, cwd, replayRoot)
const replayChildFiles = await Promise.all(childFiles.map(file => materializeFixtureCwd(file, cwd, replayRoot)))
installLlmReplay(ctx, { file: replayFile, childFiles: replayChildFiles, providers: PROVIDERS })
} }
return ctx return ctx
} }
@@ -373,15 +399,27 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}` const displayCwd = `/tmp/${basename(cwd)}`
let replayRoot: string | undefined
let ctx: Context | undefined let ctx: Context | undefined
let controller: ReturnType<typeof createTuiChat> | undefined let controller: ReturnType<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36) const terminal = new HeadlessTerminal(100, 36)
try { try {
if (!(MODE === 'record' && scenario.recorded)) {
replayRoot = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-replay-${scenario.name}-`))
}
if (scenario.seedWorkspace === true) { if (scenario.seedWorkspace === true) {
const source = join(fixtureDir(scenario), 'workspace') const source = join(fixtureDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true }) await cp(source, cwd, { recursive: true })
} }
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles) ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles, replayRoot)
if (scenario.normalizePwdResult === true) {
ctx.on('tools/post-execute', async (exec, result, next) => {
const args = exec.arguments as { command?: unknown }
return exec.name === 'bash' && args.command === 'pwd' && !result.isError
? { kind: 'accept', content: [{ type: 'text', text: '/workspace/project\n' }] }
: next()
})
}
const disposedSessions: Session[] = [] const disposedSessions: Session[] = []
ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
const workflowEvents: string[] = [] const workflowEvents: string[] = []
@@ -582,6 +620,10 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const firstHeader = events.find(event => event.type === 'request/header') const firstHeader = events.find(event => event.type === 'request/header')
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system) expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
.toContain(FILE_REFERENCE_PROMPT) .toContain(FILE_REFERENCE_PROMPT)
if (scenario.harnessSourceRoot !== undefined) {
expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
.toContain(`The DeepSeek Harness implementation checkout is at ${scenario.harnessSourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`)
}
expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
@@ -734,6 +776,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
await ctx?.fiber.dispose() await ctx?.fiber.dispose()
await terminal.dispose() await terminal.dispose()
await rm(cwd, { recursive: true, force: true }) await rm(cwd, { recursive: true, force: true })
if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
clock.mockRestore() clock.mockRestore()
} }
} }

View File

@@ -70,14 +70,7 @@ describe('web e2e: Full access confirmation', () => {
const access = page.locator('button[aria-label^="访问模式"]').first() const access = page.locator('button[aria-label^="访问模式"]').first()
await access.waitFor({ timeout: 10_000 }) await access.waitFor({ timeout: 10_000 })
// Normalize the starting preset through the real command path. The expect(await access.getAttribute('aria-label')).toBe('访问模式当前Workspace Write')
// shipped web config may already start at Full access.
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
await access.click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
.toBe('访问模式当前Workspace Write')
}
await access.click() await access.click()
await page.getByRole('menuitem', { name: 'Full access' }).click() await page.getByRole('menuitem', { name: 'Full access' }).click()

View File

@@ -108,7 +108,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
// Opening a session reaches chat content through the fixture transport. // Opening a session reaches chat content through the fixture transport.
fireEvent.click(await within(tree).findByText('Fixture 历史会话')) fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
await waitFor(() => { await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 }) }, { timeout: 10_000 })
// The write/edit turns render a real diff card through the assembled graph // The write/edit turns render a real diff card through the assembled graph

View File

@@ -112,7 +112,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
// the bash sub-call landed in the bash sample registration. // the bash sub-call landed in the bash sample registration.
const nest = page.locator('[data-subcalls]').first() const nest = page.locator('[data-subcalls]').first()
await nest.waitFor({ timeout: 10_000 }) await nest.waitFor({ timeout: 10_000 })
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1) expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
// The failing read sub-call wears the same error state a native failed // The failing read sub-call wears the same error state a native failed
// row wears (the recorded program tolerates a read of missing.txt). // row wears (the recorded program tolerates a read of missing.txt).
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1) expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
@@ -123,7 +123,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
const nest = page.locator('[data-subcalls]').first() const nest = page.locator('[data-subcalls]').first()
const frame = page.locator('[style*="grid-template-columns"]').first() const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true') expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
await nest.locator('[data-sample="bash-global"]').first().click() await nest.locator('[data-sample="bash"]').first().click()
// Tool rows do not drive layout geometry; the Session's default panel stays closed. // Tool rows do not drive layout geometry; the Session's default panel stays closed.
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
}) })

View File

@@ -0,0 +1,71 @@
// Keyless assembled-browser coverage for the goal bar over the shipped Web
// bundles and FixtureApiClient wire. The command creates a real projected
// goal in the fixture session; the golden pins the active strip, while the
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
// nor a duplicate-mutation error.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
const MODE = webSnapshotMode()
describe('web e2e: goal bar clear convergence', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('renders one active goal and clears it without exposing a stale error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
// Startup reuses the fixture workspace's blank session, keeping this
// command independent of alpha's running replay and pending question.
const input = page.getByPlaceholder('Describe what you want to build')
await input.waitFor({ timeout: 10_000 })
await input.fill('/goal guard rapid clear clicks')
await input.press('Enter')
const bar = page.locator('[data-goal-bar]')
await bar.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
const clear = bar.getByRole('button', { name: 'Clear goal' })
await clear.evaluate((button) => {
const control = button as HTMLButtonElement
control.click()
control.click()
})
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
})
})

View File

@@ -0,0 +1,5 @@
# The client-side FixtureApiClient intentionally rejects settings writes, so
# this goal-only scenario omits the durable welcome step that would otherwise
# cover the page. Onboarding owns separate assembled-browser coverage.
- id: ui-settings-general
disabled: true

View File

@@ -184,7 +184,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => { it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details')) onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
await page.getByRole('tab', { name: 'Chat' }).click() await page.getByRole('tab', { name: 'Chat' }).click()
const bashRow = page.locator('[data-sample="bash-global"]').first() const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 }) await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first() const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true') expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -194,7 +194,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// The card's own controls are outside the summary row and must not open // The card's own controls are outside the summary row and must not open
// details either — the expanded terminal card is read in place. // details either — the expanded terminal card is read in place.
await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click() await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Read summaries are host-open file links; they also must not open details. // Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first() const fileLink = page.locator('[data-variant="read"] button').first()
@@ -210,10 +210,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
// tool-row interaction): open it if a previous case left it collapsed. // tool-row interaction): open it if a previous case left it collapsed.
// Expanded, the recorded command's own output sits in the message flow, // Expanded, the recorded command's own output sits in the message flow,
// derived from the logged call/result presentations alone. // derived from the logged call/result presentations alone.
const bashRow = page.locator('[data-sample="bash-global"]').first() const bashRow = page.locator('[data-sample="bash"]').first()
await bashRow.waitFor({ timeout: 15_000 }) await bashRow.waitFor({ timeout: 15_000 })
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
await card.waitFor({ timeout: 15_000 }) await card.waitFor({ timeout: 15_000 })
// Real layout, not jsdom's stub (which computes no geometry at all): // 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 // squeeze the output pane below its content width and the line must keep

View File

@@ -143,7 +143,7 @@ describe('assembled search card', () => {
// Wait for chat content to reach the fixture's later turns (the bash sample // Wait for chat content to reach the fixture's later turns (the bash sample
// is turn 65, the grep card turn 66). // is turn 65, the grep card turn 66).
await waitFor(() => { await waitFor(() => {
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
}, { timeout: 10_000 }) }, { timeout: 10_000 })
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed // The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
// by default, so wait for the summary row, then expand it to reach the card. // by default, so wait for the summary row, then expand it to reach the card.

View File

@@ -339,19 +339,19 @@ describe('web e2e: seeded history renders through cold resume', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
// The Access chip submits `/permission <preset>` — a host command with no // The Access chip submits `/permission <preset>` — a host command with no
// model call, so the settled row renders keylessly over this cold history. // model call, so the settled row renders keylessly over this cold history.
// The row copy is the assertion: `permission · preset workspace-write`, // The row copy is the assertion: `permission · preset read-only`,
// where neither half repeats the other (the dispatched `/` and its // where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates // argument stay out of the title, and the settlement text never restates
// the command's own name). // the command's own name).
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click() await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click() await page.getByRole('menuitem', { name: 'Read Only' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 }) await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read // Scoped to the row itself, so unrelated page text that happens to read
// `permission` (a future resident slash menu) cannot satisfy or break it. // `permission` (a future resident slash menu) cannot satisfy or break it.
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset workspace-write' }) const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
expect(await row.getByText('permission', { exact: true }).count()).toBe(1) expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
expect(await row.getByText('/permission workspace-write', { exact: true }).count()).toBe(0) expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}') .split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)

View File

@@ -57,7 +57,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(await trigger.getAttribute('aria-expanded')).toBe('true') expect(await trigger.getAttribute('aria-expanded')).toBe('true')
// General is active by default; Permission, Language and Appearance are functional. // General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1) await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
// Golden of the freshly opened dialog (default zh, General active). // Golden of the freshly opened dialog (default zh, General active).
@@ -82,12 +82,12 @@ describe('web e2e: settings modal and General preferences', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
expect(existing.events.find(event => event.type === 'permission/preset')?.data) expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' }) .toEqual({ preset: 'workspace-write' })
await page.getByRole('button', { name: '设置', exact: true }).click() await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' }) const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 }) await dialog.waitFor({ timeout: 10_000 })
const selector = dialog.getByRole('button', { name: 'Full access' }) const selector = dialog.getByRole('button', { name: 'Workspace Write' })
await selector.waitFor({ timeout: 10_000 }) await selector.waitFor({ timeout: 10_000 })
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
await selector.click() await selector.click()
@@ -98,7 +98,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(document).toContain('permission:') expect(document).toContain('permission:')
expect(document).toContain('defaultPreset: read-only') expect(document).toContain('defaultPreset: read-only')
expect(existing.events.find(event => event.type === 'permission/preset')?.data) expect(existing.events.find(event => event.type === 'permission/preset')?.data)
.toEqual({ preset: 'danger-full-access' }) .toEqual({ preset: 'workspace-write' })
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
expect(created.events.map(event => [event.type, event.data])).toEqual([ expect(created.events.map(event => [event.type, event.data])).toEqual([

View File

@@ -10,6 +10,7 @@ import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-sandbox-policy'
import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-permission'
import { launchWebScaffold, type WebScaffold } from './scaffold.ts' import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
/** /**
@@ -63,7 +64,7 @@ afterEach(async () => {
scaffold = undefined scaffold = undefined
}) })
it('assembles the shipped Web catalog and keeps its access default', async () => { it('assembles the shipped Web catalog with the confined access default', async () => {
scaffold = await launchWebScaffold() scaffold = await launchWebScaffold()
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort() const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS) expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
@@ -78,8 +79,7 @@ it('assembles the shipped Web catalog and keeps its access default', async () =>
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual( expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]), expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
) )
// The Web surface keeps its shipped access default; the base's confined one expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
// reaches the TUI. Pinning both keeps a base change from moving Web silently. expect(scaffold.ctx.approval.config.policy).toBe('ask')
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('danger-full-access') expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
expect(scaffold.ctx.approval.config.policy).toBe('never')
}, 120_000) }, 120_000)

View File

@@ -588,7 +588,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
// Bash renders through the third-party sample registration. Match that // Bash renders through the third-party sample registration. Match that
// exact row: other clickable variants (for example Think disclosure) // exact row: other clickable variants (for example Think disclosure)
// may precede the tool call in document order. // may precede the tool call in document order.
const toolRow = page.locator('[data-sample="bash-global"]') const toolRow = page.locator('[data-sample="bash"]')
await toolRow.waitFor({ timeout: 120_000 }) await toolRow.waitFor({ timeout: 120_000 })
await screen(page, '08-bash-round') await screen(page, '08-bash-round')
expect(await detailsTrack(page)).toBe(0) expect(await detailsTrack(page)).toBe(0)

View File

@@ -38,7 +38,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -53,7 +53,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -33,7 +33,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -0,0 +1,8 @@
- img
- text: Ongoing Goal guard rapid clear clicks
- button "Pause goal":
- img
- button "Edit goal":
- img
- button "Clear goal":
- img

View File

@@ -28,7 +28,7 @@
- textbox "Describe what you want to build" - textbox "Describe what you want to build"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -28,7 +28,7 @@
- textbox "Describe what you want to build" - textbox "Describe what you want to build"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode on, press to turn off": Plan - button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash

View File

@@ -25,7 +25,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -22,7 +22,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -18,7 +18,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -17,7 +17,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -27,7 +27,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -36,7 +36,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img

View File

@@ -38,7 +38,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -33,7 +33,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -18,7 +18,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -31,7 +31,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -35,7 +35,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -24,7 +24,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -40,11 +40,11 @@
- img - img
- text: Context injection - text: Context injection
- img - img
- text: permission preset workspace-write - text: permission preset read-only
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Workspace Write"': Workspace Write - 'button "Access mode, current: Read Only"': Read Only
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img

View File

@@ -42,7 +42,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img

View File

@@ -11,8 +11,8 @@
- img - img
- text: 关闭 - text: 关闭
- text: 权限 选择新会话的默认权限模式 - text: 权限 选择新会话的默认权限模式
- button "Full access": - button "Workspace Write":
- text: Full access - text: Workspace Write
- img - img
- text: 语言 - text: 语言
- button "中文": - button "中文":

View File

@@ -34,7 +34,7 @@
- textbox "Message the agent" - textbox "Message the agent"
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Full access"': Full access - 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

Some files were not shown because too many files have changed in this diff Show More