diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml index ff3289544e..c24317a526 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md -2026-08-17-blank-permission-default-refresh.md: 2765462385f6a0e41bcdda8fd4a1ed532f38be2d -2026-08-17-blank-permission-default-refresh.zh.md: 8619b9f2aed01b56347af5f99439e5f356067935 +2026-08-17-blank-permission-default-refresh.md: 6e54d030eed4523974c9ca55ff8a621e107704a9 +2026-08-17-blank-permission-default-refresh.zh.md: a415ed8e729023119d454ceaa24705745b883c70 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md index 2765462385..6e54d030ee 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md @@ -10,20 +10,20 @@ The Web New Session flow reuses a workspace's blank session instead of minting a ## Decision -`dsh-permission-presets` treats a settings change as a chance to advance reusable blank placeholders. When `defaultPreset` changes, the service scans live sessions, finds sessions that have not started a turn, and switches only those whose effective permission still equals the previous default. Sessions that have started a turn are never changed. Blank sessions the user already switched away from the previous default are also left alone. +The Web workspace runtime owns candidate selection: a reusable session must be blank, belong to the selected Workspace, match its canonical cwd, and not be archived. Instead of returning that id directly, `WorkspaceRuntime.connectWorkspace` explicitly adopts it through `session.create` with `reuseWorkspaceBlank: true`. The host rechecks blankness, Workspace membership, cwd, and archive state before notification, and can resume a cold persisted placeholder before notifying optional default owners about the exact eligible session. -This keeps the existing Web blank-session reuse policy intact while making the reused placeholder observe the same default a freshly created session would receive. The update goes through the normal preset setter, so the durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the single source for projections and execution. +`dsh-permission-presets` records each `permission/preset` origin as `default`, `selection`, or `inferred`. On confirmed reuse, it advances the session to the current `defaultPreset` only when no turn has started, the latest selection is default-origin, and the effective sandbox and approval knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, and independently changed knobs remain pinned. The update goes through the normal preset writer, so durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the source for projections and execution. -This partially refines the earlier [permission default for new sessions](../feature/2026-07-31-permission-default-for-new-sessions.md) decision: started sessions and seeded resumes remain pinned, while unseeded blank placeholders may advance because the Web treats them as New Session reuse targets. +This partially refines the earlier [permission default for new sessions](../feature/2026-07-31-permission-default-for-new-sessions.md) decision: a settings write alone does not mutate an existing session, while the later confirmed reuse of a default-origin Workspace blank may advance it after live or cold adoption. ## Alternatives considered **Disable blank-session reuse after any permission settings change.** Rejected because it would leave extra hidden placeholders and make New Session less deterministic. The existing reuse policy is valuable; only stale permission defaults were wrong. -**Have the client compare a blank session's permission projection with the Settings row.** Rejected because the workspace runtime would need to understand the permission settings namespace or add a cross-plugin hook solely for this case. The permission service already owns the default and can repair its own blank placeholders. +**Have the client compare a blank session's permission projection with the Settings row.** Rejected because the workspace runtime would need to understand the permission settings namespace. The client reports only its reuse decision; the permission service owns the default-origin test and update. -**Update every blank session unconditionally.** Rejected because a user may deliberately switch the current blank session's permission before sending the first prompt. Matching only the previous default updates stale placeholders without overwriting an explicit blank-session selection. +**Scan every live blank session when Settings changes.** Rejected because the live store omits cold persisted placeholders and includes blank sessions that Web cannot reuse, such as archived or non-member sessions. It also cannot distinguish an old default from an explicit selection after restart without a durable origin. ## Consequences -A settings change may append permission facts to unseeded blank sessions, but those sessions remain blank because blankness is defined by the absence of `turn/start`. Started conversations, seeded resumes, and blank sessions with an explicit user-selected preset keep their original permission. +A Settings change does not rewrite an existing session. Confirmed New Session reuse may append permission facts to a live or cold default-origin placeholder, which remains blank because blankness is defined by the absence of `turn/start`. Started conversations, ordinary seeded resumes, explicit selections, and sessions outside the Web reuse decision keep their permission. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md index 8619b9f2ae..a415ed8e72 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md @@ -10,20 +10,20 @@ Web 新会话流程会复用工作区中的空白会话,而不是不断创建 ## Decision -`dsh-permission-presets` 将设置变更视为推进可复用空白占位会话的时机。当 `defaultPreset` 变化时,服务会扫描 live sessions,找到尚未开始过轮次的会话,并且只切换那些有效权限仍等于旧默认值的会话。已经开始过轮次的会话绝不会被改变。用户已经在空白会话中手动切离旧默认值的会话也会保持原样。 +Web workspace runtime 负责选择候选会话:可复用会话必须保持空白、属于所选 Workspace、匹配其规范 cwd,并且未归档。`WorkspaceRuntime.connectWorkspace` 不再直接返回该 id,而是通过带 `reuseWorkspaceBlank: true` 的 `session.create` 显式接纳它。host 会在通知前重新检查空白状态、Workspace 成员关系、cwd 与归档状态,并且可以先恢复冷存储中的持久占位会话,再向可选的默认值所有者通知确实符合资格的会话。 -这样既保留了既有的 Web 空白会话复用策略,也让被复用的占位会话观察到与真正新建会话相同的默认值。更新仍走常规 preset setter,因此持久的 `permission/preset`、`sandbox/mode` 与 `approval/policy` 事实继续作为投影和执行的单一来源。 +`dsh-permission-presets` 会把每条 `permission/preset` 的来源记录为 `default`、`selection` 或 `inferred`。复用确认后,只有在会话尚未开始轮次、最近选择来自默认值,并且有效沙箱与审批旋钮仍匹配该选择时,服务才会将它推进到当前 `defaultPreset`。显式选择、由旧旋钮推断或没有来源标记的旧选择,以及独立变更的旋钮都会保持固定。更新仍走常规 preset writer,因此持久的 `permission/preset`、`sandbox/mode` 与 `approval/policy` 事实继续作为投影和执行的来源。 -这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.md)决策:已经开始的会话和带 seed 的恢复仍保持固定,而未带 seed 的空白占位会话可以推进,因为 Web 会将它们作为新会话复用目标。 +这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.md)决策:单独写入设置不会改变既有会话,而 Web 之后确认复用、且权限来自默认值的 Workspace 空白会话可以在 live 或冷接纳后推进。 ## Alternatives considered **权限设置变化后禁用空白会话复用。** 拒绝,因为这会留下额外的隐藏占位会话,并让新会话行为更不确定。既有复用策略有价值;错误只在于权限默认值过期。 -**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace,或为这个场景新增跨插件 hook。权限服务已经拥有默认值,也能修复自己的空白占位会话。 +**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace。客户端只报告自己的复用决定;权限服务拥有默认来源检查和更新。 -**无条件更新所有空白会话。** 拒绝,因为用户可能在发送第一条 prompt 前,刻意切换当前空白会话的权限。只匹配旧默认值可以更新过期占位会话,同时避免覆盖明确的空白会话选择。 +**Settings 变化时扫描所有 live 空白会话。** 拒绝,因为 live store 会漏掉冷存储中的持久占位会话,同时包含 Web 无法复用的空白会话,例如已归档或不属于 Workspace 的会话;重启后若没有持久来源,也无法区分旧默认值与显式选择。 ## Consequences -设置变更可能向未带 seed 的空白会话追加权限事实,但这些会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、带 seed 的恢复,以及已有用户显式选择预设的空白会话都会保留原权限。 +Settings 变更不会改写既有会话。确认的新会话复用可能向 live 或冷存储中、权限来自默认值的占位会话追加权限事实;该会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、普通 seed 恢复、显式选择,以及不在 Web 复用决定中的会话都会保留原权限。 diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml index 67d3267414..a5e7814c64 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md -2026-07-31-gui-full-access-confirmation.md: c8920981ee09c0496f045962e0736bf0cc39c59b -2026-07-31-gui-full-access-confirmation.zh.md: 8c453a3cb9f4efce079115ff5cf5d4a44dc06796 +2026-07-31-gui-full-access-confirmation.md: 5822ae26d36329b865a05c59dd672daa55fb6224 +2026-07-31-gui-full-access-confirmation.zh.md: e954e6f4ec1d5591a1d55dc1b98f2f0d01b7b242 diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md index c8920981ee..5822ae26d3 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md @@ -16,7 +16,7 @@ Switching the web client to `danger-full-access` was a single click on a permiss - The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys. - The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending. - The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched. -- The full-access product label intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. Later, the same localized built-in-label rule was extended to the safer shipped presets while preserving the same wire names ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)). +- The full-access product label overrides the kebab-to-title display transform when the host keeps the canonical built-in name; an explicit host label wins. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. The same conditional localization applies to the safer shipped presets ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md index 8c453a3cb9..e954e6f4ec 100644 --- a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md @@ -16,7 +16,7 @@ Status: implemented - composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。 - `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。 - 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。 -- 完全权限产品标签在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。后来,同一个本地化内置标签规则扩展到了更安全的随附预设,同时仍保留相同的 wire 名称([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md))。 +- 当 host 保留内置预设的规范名称时,完全权限产品标签会覆盖 kebab 转 Title Case 的显示变换;显式 host 标签优先。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。同一个条件式本地化规则也适用于更安全的随附预设([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md))。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index c5a2281059..9276d1df2d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 7383d4b0e51f6c5570818e1b0c3163b7c2f26146 -2026-07-31-permission-default-for-new-sessions.zh.md: 398bb668cc9b08cc989ad099897d4aab17844015 +2026-07-31-permission-default-for-new-sessions.md: 818c105ce880f7b67bfd28030502bf6775e1a039 +2026-07-31-permission-default-for-new-sessions.zh.md: a443190c2e53b6195edc44e98085b276eb9cd8b4 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 7383d4b0e5..818c105ce8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t `dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them. -The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change does not change started conversations. Reusable, unseeded blank placeholders that still carry the previous default advance to the new default before Web New Session reuses them; this later bug fix is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. +The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records that the selection came from the default. Those facts pin the permission selected at creation, so a later Settings change does not change started conversations. When Web later confirms a Workspace blank for New Session reuse, the host explicitly adopts that live or cold session and advances it only if its latest selection remains default-origin and effective; this refinement is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). An ordinary seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default merely by resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. @@ -20,7 +20,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c ## Consequences -Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter a started 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. Reusable blank placeholders may receive a new pinned triplet when they still reflect the previous default. 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 an existing session by itself. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. A Workspace blank may receive a new pinned triplet only when Web confirms it as the reuse target and its effective selection is still default-origin. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly. 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. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 398bb668cc..a443190c2e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` `dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。 -服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。仍可复用、未带 seed、且还保持旧默认值的空白占位会话会在 Web 新会话复用前推进到新默认值;这项后续 bug 修复记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。 +服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实会记录该选择来自默认值。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。Web 之后把某个 Workspace 空白会话确认为新会话复用目标时,host 会显式接纳这个 live 或冷会话,并且只在其最近选择仍来自默认值且继续有效时推进;这项细化记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。普通的带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;仅仅恢复会话时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 @@ -20,7 +20,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 ## 后果 -在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已经开始的已打开会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。可复用空白占位会话若仍反映旧默认值,则可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 +在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但单凭该操作不会改变既有会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。只有当 Web 把某个 Workspace 空白会话确认为复用目标,并且其有效选择仍来自默认值时,该会话才可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。 组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml index 1ba8316340..0f031da815 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md -2026-07-31-workspace-write-surface-default.md: 2ce4d7b45ad7058768f412302eb86ac332cabfe9 -2026-07-31-workspace-write-surface-default.zh.md: 40b97812eb17b5c11e422ff23cd718576c9c4364 +2026-07-31-workspace-write-surface-default.md: e096028e07f1e7905fef568c435ad7f3d1d19138 +2026-07-31-workspace-write-surface-default.zh.md: f1794ca6a1850441acb0c45274ee94b6dd7ae919 diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md index 2ce4d7b45a..e096028e07 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md @@ -12,7 +12,7 @@ The shipped terminal and browser surfaces exposed the same coding tools under di [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.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-presets`. 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. +A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Changing the General-settings default affects fresh sessions, while started and ordinary resumed sessions retain their logged permission. A later Web New Session action may advance a default-origin Workspace blank after confirming and adopting that exact reuse target, as recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). 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. @@ -30,6 +30,6 @@ The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads ## 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. +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. A Settings write does not rewrite logged session permissions; confirmed reuse may append a new default-origin permission triplet to the selected blank Workspace session. 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. diff --git a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md index 40b97812eb..f1794ca6a1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md @@ -12,7 +12,7 @@ Status: implemented [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission-presets`。组合回退值为 `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` 命令。 +真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。更改「通用」设置中的默认值会影响新建会话,而已经开始的会话和普通恢复会话保留日志中记录的权限。Web 之后执行新会话操作时,可以在确认并接纳确切复用目标后推进权限来自默认值的 Workspace 空白会话;该规则记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。 该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝告终,不会退回不受限命令。 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。 +全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。Settings 写入不会改写会话日志中的权限;复用确认可以向选中的空白 Workspace 会话追加新的默认来源权限三元组。 由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝告终,而不会显示权限询问。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 078f5e8777..b1d47e3009 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -133,7 +133,7 @@ describe('web e2e: settings modal and General preferences', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) expect(existing.events.find(event => event.type === 'permission/preset')?.data) - .toEqual({ preset: 'workspace-write' }) + .toEqual({ preset: 'workspace-write', origin: 'default' }) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) @@ -149,11 +149,11 @@ describe('web e2e: settings modal and General preferences', () => { expect(document).toContain('permission:') expect(document).toContain('defaultPreset: read-only') expect(existing.events.find(event => event.type === 'permission/preset')?.data) - .toEqual({ preset: 'workspace-write' }) + .toEqual({ preset: 'workspace-write', origin: 'default' }) const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after')) expect(created.events.map(event => [event.type, event.data])).toEqual([ - ['permission/preset', { preset: 'read-only' }], + ['permission/preset', { preset: 'read-only', origin: 'default' }], ['sandbox/mode', { mode: 'read-only' }], ['approval/policy', { policy: 'ask' }], ]) @@ -170,7 +170,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(confirmedDocument).toContain('defaultPreset: danger-full-access') const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed')) expect(confirmed.events.map(event => [event.type, event.data])).toEqual([ - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'default' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md index 306ad17f07..c083b3aea0 100644 --- a/apps/web/tests/snapshots/access-confirmation/ui.expected.md +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -3,7 +3,7 @@ - button "Close": - img - img - - paragraph: 启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 + - paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 - checkbox "我已了解风险,并愿意继续" - text: 我已了解风险,并愿意继续 - button "取消" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2e99c8713a..116e1da9f7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 646db6e251fdcbfcccea354de97f8db394abc8c2 -config-catalog.zh.md: 0b32dd18e1d8ab1ca24ce3e5f144402d0d3fad76 +config-catalog.md: 29d4e46769a056515385f5d1ccf70beb7c32f946 +config-catalog.zh.md: 54c6b7782625585dfd0bd4d52287d70841981484 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 646db6e251..29d4e46769 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1318,8 +1318,9 @@ export interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -1339,7 +1340,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/interaction/permission-presets/src/index.ts:150`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0b32dd18e1..54c6b77826 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1320,8 +1320,9 @@ export interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -1341,7 +1342,7 @@ export interface PresetSpec { 依赖:[`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -来源:[`packages/interaction/permission-presets/src/index.ts:140`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index f73e849acf..b3c99a779a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: cf796ca322027886b1a0b78d69ac1d3a98d9459f -persistence-catalog.zh.md: 5f82254813ef6fb0b3c9244a2bd03fb2177e5559 +persistence-catalog.md: a72f00e5edc25d1c52b3c842cea58470a21f629e +persistence-catalog.zh.md: 90e18e63b07720468b31342d4689aa1e17594e8f diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index cf796ca322..a72f00e5ed 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -502,15 +502,18 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src ```ts persistence-catalog /** - * Records the selected preset as durable, log-only user intent. The knob + * Records the selected preset and whether it came from the session + * default, an explicit selection, or legacy-knob inference. The knob * events follow in the same turn and control execution; this event stays * out of the model transcript and lets {@link effectivePermissionPreset} - * preserve a selection when bundles match. + * preserve a selection when bundles match. `origin` is optional so logs + * written before origin tracking remain readable but are never mistaken + * for refreshable defaults. */ -'permission/preset': { preset: string } +'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 5f82254813..90e18e63b0 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -504,15 +504,18 @@ export type SessionEvent = { ```ts persistence-catalog /** - * Records the selected preset as durable, log-only user intent. The knob + * Records the selected preset and whether it came from the session + * default, an explicit selection, or legacy-knob inference. The knob * events follow in the same turn and control execution; this event stays * out of the model transcript and lets {@link effectivePermissionPreset} - * preserve a selection when bundles match. + * preserve a selection when bundles match. `origin` is optional so logs + * written before origin tracking remain readable but are never mistaken + * for refreshable defaults. */ -'permission/preset': { preset: string } +'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` diff --git a/docs/subsystems/permission-presets.i18n.yaml b/docs/subsystems/permission-presets.i18n.yaml index 44c1f00455..2bcca703b0 100644 --- a/docs/subsystems/permission-presets.i18n.yaml +++ b/docs/subsystems/permission-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/permission-presets.md -permission-presets.md: e16a0f11cb3df48d54387ffd39157887d41764ff -permission-presets.zh.md: 989d6033fff49b62cb17968dc5f5deb15f690c84 +permission-presets.md: 0908e3dd22c16f94c4d09a2cc0d0e5efc487e6b3 +permission-presets.zh.md: 73cde1702a76bbef73b47353e0f312f57ad1d761 diff --git a/docs/subsystems/permission-presets.md b/docs/subsystems/permission-presets.md index e16a0f11cb..0908e3dd22 100644 --- a/docs/subsystems/permission-presets.md +++ b/docs/subsystems/permission-presets.md @@ -34,8 +34,9 @@ interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -91,6 +92,17 @@ Owns the deployment's permission presets and their write path. Requires a confin */ current(events: readonly SessionEvent[]): string +/** + * Advance one blank session after the host has confirmed it as the exact + * Web New Session reuse target. Only a still-effective + * default-origin selection advances; a started session, an explicit pick, + * legacy origin-less data, or independently changed knobs remain pinned. + * This is the permission-side half of the Web candidate selection and the + * host's blankness, membership, cwd, and archive verification. + * @param session - the live session selected for Workspace blank reuse. + */ +refreshDefaultForReuse(session: Session): void + /** * Build the whole select value for one folded knob state: every table * option in declaration order, `custom` appended exactly while derived. @@ -127,5 +139,5 @@ set(session: Session, name: string): void Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:169`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/docs/subsystems/permission-presets.zh.md b/docs/subsystems/permission-presets.zh.md index 989d6033ff..73cde1702a 100644 --- a/docs/subsystems/permission-presets.zh.md +++ b/docs/subsystems/permission-presets.zh.md @@ -34,8 +34,9 @@ interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -91,6 +92,17 @@ Owns the deployment's permission presets and their write path. Requires a confin */ current(events: readonly SessionEvent[]): string +/** + * Advance one blank session after the host has confirmed it as the exact + * Web New Session reuse target. Only a still-effective + * default-origin selection advances; a started session, an explicit pick, + * legacy origin-less data, or independently changed knobs remain pinned. + * This is the permission-side half of the Web candidate selection and the + * host's blankness, membership, cwd, and archive verification. + * @param session - the live session selected for Workspace blank reuse. + */ +refreshDefaultForReuse(session: Session): void + /** * Build the whole select value for one folded knob state: every table * option in declaration order, `custom` appended exactly while derived. @@ -127,5 +139,5 @@ set(session: Session, name: string): void Types: [Session](session.md) · [SessionEvent](session.md) -Source: [`packages/interaction/permission-presets/src/index.ts:169`](../../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts) diff --git a/packages/client/runtime/src/client/contract/sessions-port.ts b/packages/client/runtime/src/client/contract/sessions-port.ts index 551c3a16ee..8b9bf49229 100644 --- a/packages/client/runtime/src/client/contract/sessions-port.ts +++ b/packages/client/runtime/src/client/contract/sessions-port.ts @@ -32,11 +32,15 @@ export interface SessionsPort { /** Observable list snapshot (read face only; writes stay inside the sessions domain). */ readonly list: ObservableSnapshot /** - * Create a session on the host. - * @param opts - target workspace. - * @returns the new session id. + * Create or explicitly adopt a session on the host. + * @param opts - target workspace and optional confirmed blank-reuse id. + * @returns the created or adopted session id. */ - create(opts: { workspaceId: WorkspaceId }): Promise + create(opts: { + workspaceId: WorkspaceId + sessionId?: SessionId + reuseWorkspaceBlank?: true + }): Promise /** * Select a session as current. * @param id - session id (must exist in the list store). diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 13aa20d1c8..bb8ed7bd6c 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -534,10 +534,18 @@ export class SessionManager { * @returns the create result. */ async create( - opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}, + opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + reuseWorkspaceBlank?: true + } = {}, ): Promise> { try { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.reuseWorkspaceBlank === undefined ? {} : { reuseWorkspaceBlank: opts.reuseWorkspaceBlank }), + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index c66da4e0d3..5e23b3a10a 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -482,7 +482,12 @@ export class SessionRuntime implements ISessions { * @returns the new session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + reuseWorkspaceBlank?: true + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c23e3b3a6c..bfce0c6e28 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -53,7 +53,7 @@ export class WorkspaceRuntime implements IWorkspaces { readonly list: SnapshotStore /** Workspace baseline and frame owner. */ private readonly manager: WorkspaceManager - /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */ + /** In-flight blank-session connects keyed by workspace (reuse or create). */ private readonly connecting = new Map>() /** Guards the runtime-owned one-shot initial-selection subscription. */ private initialSelectionStarted = false @@ -76,9 +76,11 @@ export class WorkspaceRuntime implements IWorkspaces { /** * Resolve the session a New Session flow lands in once this Workspace is - * chosen: reuse the workspace's existing blank session when one is in the - * list mirror, else create a fresh one on the host (`session.create` births - * the full Session+Agent — the client holds no intermediate state). The + * chosen: explicitly adopt the workspace's existing blank session when one + * is in the list mirror, else create a fresh one on the host + * (`session.create` births or resumes the full Session+Agent — the client + * holds no intermediate state). The adoption tells optional default owners + * that this exact session passed the reuse checks. * caller owns navigation: take the returned id to `sessions.open`. * Resolution guarantee (both arms): the returned id is already in the list * store and `sessions.binding(id)` resolves synchronously — draft hand-off @@ -107,7 +109,13 @@ export class WorkspaceRuntime implements IWorkspaces { const summary = sessions.byId[id] if (summary !== undefined && summary.blank && summary.cwd === workspace.path && workspace.sessionIds.includes(summary.id) - && !archived.includes(summary.id)) return summary.id + && !archived.includes(summary.id)) { + return this.sessions.create({ + workspaceId, + sessionId: summary.id, + reuseWorkspaceBlank: true, + }) + } } const attempt = this.sessions.create({ workspaceId }) .finally(() => { this.connecting.delete(workspaceId) }) diff --git a/packages/client/runtime/tests/workspaces-service.client.spec.ts b/packages/client/runtime/tests/workspaces-service.client.spec.ts index cfdfa98e9b..68736f0a58 100644 --- a/packages/client/runtime/tests/workspaces-service.client.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.client.spec.ts @@ -242,20 +242,28 @@ describe('WorkspaceRuntime', () => { { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' }, ] as never[], })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'), + })) await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() - // Hit: same workspace → the parked member blank comes back (the earlier - // cwd-matching non-member stray is skipped), no create RPC. + // Hit: same workspace → the parked member blank is explicitly adopted + // after the earlier cwd-matching non-member stray is skipped. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) + expect(api.callsOf('session.create')).toEqual([{ + workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true, + }]) // Resolution guarantee: the id is binding-resolvable synchronously. expect(sessions.binding(sid('s-blank'))).toBeDefined() // Miss: beta has only a non-blank session → host create with workspaceId. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') })) await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }]) + expect(api.callsOf('session.create')).toEqual([ + { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true }, + { workspaceId: 'beta' }, + ]) // Same guarantee on the create arm (draft hand-off writes the machine pre-open). expect(sessions.binding(sid('s-fresh'))).toBeDefined() @@ -263,7 +271,11 @@ describe('WorkspaceRuntime', () => { // never reused, a fresh accounted session is created instead. api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') })) await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3') - expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }]) + expect(api.callsOf('session.create')).toEqual([ + { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true }, + { workspaceId: 'beta' }, + { workspaceId: 'gamma' }, + ]) // Unknown workspace fails loud instead of silently creating in nowhere. await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/) @@ -284,6 +296,9 @@ describe('WorkspaceRuntime', () => { api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[], })) + api.onCreate = payload => Promise.resolve(ok({ + sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'), + })) await Promise.all([workspaces.refresh(), sessions.refresh()]) await Promise.resolve() const session = sessions.binding(sid('s-blank'))!.session @@ -292,7 +307,9 @@ describe('WorkspaceRuntime', () => { await Promise.resolve() // Failure leaves blank intact, so the same session is still the reuse hit. await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank') - expect(api.callsOf('session.create')).toEqual([]) + expect(api.callsOf('session.create')).toEqual([{ + workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true, + }]) }) it('returns created Workspaces and preserves Host business errors', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 3c60cc8dec..4282da73ed 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: e3540fcb3e866beac7a63bbdac20a7b1200510a4 -README.zh.md: 554f7b42d08b1873100641cabe63d04547128c4d +README.md: 6cc9ce5829a18901604e4d5d7a8f37050cdb107b +README.zh.md: 0cc80d3e3a5926c20da8aeee5beac8ff63f9e3cd diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e3540fcb3e..6cc9ce5829 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ The view ring is a slot: the strict session-body registration declares the sessi Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose built-in preset ids render as localized product labels and unknown kebab-case names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose built-in preset ids render as localized product labels only when the host keeps their canonical names, while explicit host labels remain unchanged and unknown kebab-case names render in title case. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 554f7b42d0..0cc80d3e3a 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -24,7 +24,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 -审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中内置预设 id 渲染为本地化产品标签,未知 kebab-case 预设名仍渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 +审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中内置预设 id 仅在 host 保留规范名称时渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名仍渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 `TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.md)。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index d82e36b935..e1d5b4e3c9 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -70,7 +70,7 @@ export const zh = { 'access.preset.workspaceWrite': '可写入工作区', 'access.preset.fullAccess': '完全权限', 'access.confirm.title': '确认启用完全权限?', - 'access.confirm.description': '启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用完全权限', diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index c95065d11d..b7ba37a7e0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -15,14 +15,14 @@ const FULL_ACCESS = 'danger-full-access' const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' -const permissionGlyphs = { - 'read-only': ( +const permissionGlyphs = new Map([ + ['read-only', ( - ), - 'workspace-write': ( + )], + ['workspace-write', ( @@ -30,19 +30,19 @@ const permissionGlyphs = { - ), - [FULL_ACCESS]: ( + )], + [FULL_ACCESS, ( - ), -} as Record + )], +]) /** Glyph for a permission option value; host-configured names outside the design set get none. */ function permissionGlyph(value: string): ReactNode | undefined { - return permissionGlyphs[value] + return permissionGlyphs.get(value) } /** @@ -54,10 +54,19 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +const BUILT_IN_PERMISSION_NAMES = new Map([ + ['read-only', 'Read Only'], + ['workspace-write', 'Workspace Write'], + [FULL_ACCESS, 'Full access'], +]) + function permissionLabel(value: string, name: string, t: ComposerBarProps['t']): string { - if (value === 'read-only') return t('access.preset.readOnly') - if (value === 'workspace-write') return t('access.preset.workspaceWrite') - if (value === FULL_ACCESS) return t('access.preset.fullAccess') + const builtInName = BUILT_IN_PERMISSION_NAMES.get(value) + if (builtInName !== undefined && (name === value || name === builtInName)) { + if (value === 'read-only') return t('access.preset.readOnly') + if (value === 'workspace-write') return t('access.preset.workspaceWrite') + if (value === FULL_ACCESS) return t('access.preset.fullAccess') + } return displayName(name) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 8e5939b833..92b946ff04 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1280,6 +1280,25 @@ describe('command launcher chrome and control seats', () => { expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false) }) + it('the Access chip preserves host labels for built-in preset values', () => { + const permissions = { + options: [ + { value: 'read-only', name: 'Review Only' }, + { value: 'workspace-write', name: 'Project Files' }, + { value: 'danger-full-access', name: 'Operator Mode' }, + { value: 'custom-mode', name: 'custom-mode' }, + { value: '__proto__', name: '__proto__' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions }) + const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement + expect(trigger.textContent).toBe('Project Files') + fireEvent.click(trigger) + expect(view.getAllByRole('menuitem').map(item => item.textContent)) + .toEqual(['Review Only', 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__']) + }) + it('requires explicit risk acknowledgement before submitting full access', async () => { const command = vi.fn(() => Promise.resolve(true)) const permissions = { diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index d4ef24df91..a42c6f5e6a 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/packages/client/ui-permission-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md -README.md: 006a5855cf17ff722e571468360ab3d661747131 -README.zh.md: fd5ad3728e8600382b2ff2dfe760bb3aca7734f1 +README.md: 2d2f8243f878ef6adc5d969494bdc8fb5abf7ed1 +README.zh.md: 05d555d1dd30811c62b4e8ee2daa7e9242f99512 diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index 006a5855cf..2d2f8243f8 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/packages/client/ui-permission-presets/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies to later sessions; the host also advances reusable blank placeholders that still carry the previous default so New Session reuse follows the setting. Choosing the full-access preset requires an explicit risk acknowledgement before the row writes it. +Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. The value applies to fresh sessions; when Web later confirms an existing Workspace blank as the New Session reuse target, the host refreshes a still-default-derived permission after adopting that exact live or cold session. Choosing the full-access preset requires an explicit risk acknowledgement before the row writes it. -The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, built-in preset ids rendered as localized product labels, and unknown kebab-case preset names rendered as title-case labels. A pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row. +The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission ` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, canonical built-in names rendered as localized product labels, explicit host labels preserved, and unknown kebab-case preset names rendered in title case. A pick submits the `/permission ` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row. The `/client` exports are the plugin body (`apply`/`inject`). @@ -19,3 +19,4 @@ No direct invalidation; the knob consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution. +- **Preset descriptions come from the host** — localized built-in labels may therefore appear beside a description written in another language. diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index fd5ad3728e..05d555d1dd 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值作用于后续会话;host 也会推进仍可复用、且还保持旧默认值的空白占位会话,使新会话复用继续跟随设置。选择完全权限预设时必须先显式确认风险,该行随后才会写入。 +面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值作用于新建会话;Web 之后把某个既有 Workspace 空白会话确认为新会话复用目标时,host 会接纳这个确切的 live 或冷会话,并刷新仍来自默认值的权限。选择完全权限预设时必须先显式确认风险,该行随后才会写入。 -当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,内置预设 id 渲染为本地化产品标签,未知 kebab-case 预设名仍渲染为 Title Case 标签。选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。 +当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission ` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,内置预设的规范名称渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名仍渲染为 Title Case 标签。选中即提交 `/permission ` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。 `/client` 导出面为插件本体(`apply`/`inject`)。 @@ -19,3 +19,4 @@ ## 已知限制与暂缓事项 - **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。 +- **预设描述来自 host**:本地化的内置标签旁边可能显示另一种语言编写的描述。 diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index fcabfff032..8a20098515 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/packages/client/ui-permission-presets/src/client/index.ts @@ -10,8 +10,8 @@ * write through one path and the pushed projection frame is the one * confirmation. The Full access row carries the same explicit risk gate as * the composer chip; the shared popup shell owns the modal mechanics. - * The General-settings row separately writes the default preset for sessions - * created later through the host Settings API. + * The General-settings row separately writes the default preset for fresh + * sessions and eligible confirmed blank reuse through the host Settings API. */ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). diff --git a/packages/client/ui-permission-presets/src/client/locales.ts b/packages/client/ui-permission-presets/src/client/locales.ts index 3213c4a074..8a1ec2049c 100644 --- a/packages/client/ui-permission-presets/src/client/locales.ts +++ b/packages/client/ui-permission-presets/src/client/locales.ts @@ -41,7 +41,7 @@ export const accessZh = { 'preset.workspaceWrite': '可写入工作区', 'preset.fullAccess': '完全权限', 'confirm.title': '确认启用完全权限?', - 'confirm.description': '启用完全权限后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'confirm.acknowledge': '我已了解风险,并愿意继续', 'confirm.cancel': '取消', 'confirm.enable': '启用完全权限', diff --git a/packages/client/ui-permission-presets/src/client/presentation.ts b/packages/client/ui-permission-presets/src/client/presentation.ts index 7a15b8960b..a46939b196 100644 --- a/packages/client/ui-permission-presets/src/client/presentation.ts +++ b/packages/client/ui-permission-presets/src/client/presentation.ts @@ -42,6 +42,8 @@ export function displayPermissionPreset( t?: (key: PermissionPresetLabelKey) => string, ): string { const key = PRESET_LABEL_KEYS.get(value) - if (key !== undefined) return t?.(key) ?? DEFAULT_PRESET_LABELS[key] + if (key !== undefined && (name === value || name === DEFAULT_PRESET_LABELS[key])) { + return t?.(key) ?? DEFAULT_PRESET_LABELS[key] + } return displayPresetName(name) } diff --git a/packages/client/ui-permission-presets/src/client/settings-store.ts b/packages/client/ui-permission-presets/src/client/settings-store.ts index 89007c36e6..4ccb6e48b6 100644 --- a/packages/client/ui-permission-presets/src/client/settings-store.ts +++ b/packages/client/ui-permission-presets/src/client/settings-store.ts @@ -20,7 +20,7 @@ import { displayPermissionPreset } from './presentation.ts' /** Permission's settings namespace on the host wire. */ export const PERMISSION_SETTINGS_NS = 'permission' -/** One selectable new-session default. */ +/** One selectable fresh-session and confirmed-reuse default. */ export interface PermissionDefaultOption { /** Preset key written to Settings. */ id: string @@ -121,7 +121,8 @@ export class PermissionPresetSettingsController { } /** - * Persist one preset as the default for subsequently created sessions. + * Persist one preset as the default for fresh sessions and eligible + * confirmed blank reuse. * A selection made while one is already saving is ignored — the row's * control is disabled during the save, so this only drops programmatic * double-submits rather than user intent. diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index a5fb2583f3..d9c58c0923 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts @@ -138,12 +138,16 @@ describe('ui-permission browser plugin', () => { confirmLabel: 'Enable Full access', }) b.values.set(sid('s1'), { ...SELECT, options: [ + { value: 'workspace-write', name: 'Project Files' }, + { value: 'danger-full-access', name: 'Operator Mode' }, { value: 'custom-mode', name: 'custom-mode' }, { value: '__proto__', name: '__proto__' }, { value: 'plain', name: 'Ask Every Time' }, ] }) const passthrough = await c.ui.options(proj, new AbortController().signal) - expect(passthrough.map(option => option.label)).toEqual(['Custom Mode', '__proto__', 'Ask Every Time']) + expect(passthrough.map(option => option.label)).toEqual([ + 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time', + ]) // A projection that vanished between availability and open throws. expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal)) .toThrow(/not available on this host/) diff --git a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts index ad2831ac48..9e87fbbc10 100644 --- a/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts +++ b/packages/client/ui-permission-presets/tests/settings-store.client.spec.ts @@ -47,12 +47,12 @@ function permissionController(api: object) { } describe('permission settings store', () => { - it('derives dynamic options and built-in labels from the descriptor schema', () => { + it('derives dynamic options and preserves labels from the descriptor schema', () => { expect(resolveDefault(view('read-only'))).toEqual({ currentValue: 'read-only', options: [ { id: 'read-only', label: 'Read Only' }, - { id: 'workspace-write', label: 'Workspace Write' }, + { id: 'workspace-write', label: 'Workspace' }, ], }) const single = { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 707431600b..fdf40e266a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -923,6 +923,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'events', description: 'the session\'s events in log order.' }], returns: 'the effective preset name, or `custom` when nothing matches.', }, + { + signature: 'refreshDefaultForReuse(session: Session): void', + description: 'Advance one blank session after the host has confirmed it as the exact Web New Session reuse target. Only a still-effective default-origin selection advances; a started session, an explicit pick, legacy origin-less data, or independently changed knobs remain pinned. This is the permission-side half of the Web candidate selection and the host\'s blankness, membership, cwd, and archive verification.', + parameters: [{ name: 'session', description: 'the live session selected for Workspace blank reuse.' }], + }, { signature: 'selectFor(state: KnobState): PermissionSelect', description: 'Build the whole select value for one folded knob state: every table option in declaration order, `custom` appended exactly while derived.', diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 946e27ded3..44d3a6209d 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -78,13 +78,15 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c675b60907..9e9372cf4d 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -18,6 +18,9 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +// Type-only: resolves the optional permission-default owner notified after +// the Web proposes and the Host verifies a Workspace blank reuse target. +import type {} from '@deepseek-ai/dsh-permission-presets' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' @@ -471,7 +474,9 @@ function jobViews(snapshots: readonly JobSnapshot[]): JobView[] { * turn is one model-loop execution). Standalone plugin events — command * lifecycle records, plan/mode, titles, goals — never open a turn, so * running `/plan` or `/goal` on a fresh session keeps it blank - * (list-hidden, reusable). + * (list-hidden, reusable). `session.create` combines this predicate with the + * Workspace membership and archive state before a confirmed reuse can notify + * permission-default owners; they do not maintain a second blankness rule. */ function sessionBlank(session: Session): boolean { return !session.events.some(event => event.type === 'turn/start') @@ -2119,8 +2124,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd const requestedPreset = request.payload.agentPreset + const refreshDefaultAfterReuse = request.payload.reuseWorkspaceBlank === true + && workspace !== undefined + && workspace.sessionIds.includes(sessionId) + && !ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) + let adopted: Agent try { - await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) + adopted = await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset) } catch (error: unknown) { if (error instanceof AgentPresetConflict) { return err(request, { @@ -2166,6 +2176,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } + if (refreshDefaultAfterReuse && sessionBlank(adopted.session)) { + ctx.get('permissionPresets')?.refreshDefaultForReuse(adopted.session) + } // Echo the composition the session RUNS so a client can label it // without waiting for the next list refresh — the create is the commit // point that knows it (a caller that named none gets the default). @@ -2174,8 +2187,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // switched while blank runs a preset its header no longer names, so // echoing the header would contradict both the adoption this call just // allowed and the row `session.list` serves for the same session. - const created = ctx.agents.get(sessionId) - const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session) + const createdPreset = resolveSessionPreset(adopted.session) return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } }) }, diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index c415015776..930636915c 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -104,9 +104,14 @@ export const sessionCreateRequestSchema = z.object({ cwd: z.string().optional(), sessionId: sessionIdSchema.optional(), agentPreset: z.string().optional(), + reuseWorkspaceBlank: z.literal(true).optional(), }).refine( payload => payload.workspaceId === undefined || payload.cwd === undefined, { message: 'session.create accepts workspaceId or cwd, not both' }, +).refine( + payload => payload.reuseWorkspaceBlank !== true + || (payload.workspaceId !== undefined && payload.sessionId !== undefined), + { message: 'session.create reuseWorkspaceBlank requires workspaceId and sessionId' }, ) satisfies z.ZodType>> /** session.create response value. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2e7c9b22f4..0d2da8350c 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -250,6 +250,11 @@ export interface SessionsApi { * session, while a different cwd fails with `session-conflict`. Workspace * creation attaches the session after publication; an attach failure * returns `workspace-attach-failed` with the published session id. + * `reuseWorkspaceBlank: true` is valid only with both `workspaceId` and an + * existing `sessionId`; it reports the Web workspace runtime's New Session + * reuse candidate. The host notifies optional session-default owners only + * while that session is still blank, belongs to the Workspace, matches its + * cwd, and is not archived. * * `agentPreset` names the composition the new session's agent is built * from; omitted, the effective default applies — the user's stored choice @@ -258,7 +263,13 @@ export interface SessionsApi { * id fails with `agent-preset-not-found`, and a preset whose composition * cannot be mounted fails with `agent-preset-invalid`. */ - create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>): + create(request: RpcRequest<{ + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + agentPreset?: string + reuseWorkspaceBlank?: true + }>): Promise> /** diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index ef642082ac..3cc780d300 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { Session } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -214,6 +214,62 @@ describe('sessions.list cold merge', () => { }) }) +describe('session.create cold blank reuse', () => { + it('resumes the persisted target before notifying the permission-default owner', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserQuestionService) + const sessionId = sid('cold-workspace-blank') + const meta = header(sessionId, 1000) + const events = [ + { type: 'permission/preset', seq: 0, time: 1, data: { preset: 'workspace-write', origin: 'default' } }, + { type: 'sandbox/mode', seq: 1, time: 2, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: 2, time: 3, data: { policy: 'ask' } }, + ] as SessionEvent[] + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + locate: () => undefined, + } as never) + const resumedSession = Session.create(sessionId, events, meta) + const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent + const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ + agent: resumedAgent, + dispose: () => Promise.resolve(), + }) + const attachSession = vi.fn(() => Promise.resolve()) + const workspace = { + id: 'workspace-1', + path: '/proj', + sessionIds: [sessionId], + attachSession, + } + ctx.provide('workspaceRegistry', { + get: () => workspace, + list: () => [workspace], + archivedSessionIds: [], + } as never) + const refreshDefaultForReuse = vi.fn() + ctx.provide('permissionPresets', { refreshDefaultForReuse } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + + const response = await api.sessions.create(request({ + workspaceId: 'workspace-1' as never, + sessionId, + reuseWorkspaceBlank: true as const, + })) + + expect(response.result.ok).toBe(true) + expect(resume).toHaveBeenCalledOnce() + expect(attachSession).toHaveBeenCalledWith(sessionId) + expect(refreshDefaultForReuse).toHaveBeenCalledWith(resumedSession) + }) +}) + describe('attached updatedAt tracks human prompts', () => { it('ignores pickup and non-prompt work after the latest human message', async () => { const ctx = new Context() diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 54bdb015e4..bc4382a984 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -64,6 +64,7 @@ async function harness( extras: { openPath?: (path: string, signal: AbortSignal) => Promise canOpenPath?: () => boolean + refreshDefaultForReuse?: (session: Session) => void } = {}, ) { const ctx = new Context() @@ -102,6 +103,11 @@ async function harness( // Structural picker fake: the gateway only reads capability(); a stable // object per harness mirrors the seam's stability contract. ctx.provide('directoryPicker', { capability: () => picker } as never) + if (extras.refreshDefaultForReuse !== undefined) { + ctx.provide('permissionPresets', { + refreshDefaultForReuse: extras.refreshDefaultForReuse, + } as never) + } const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: root, @@ -364,6 +370,63 @@ describe('workspace.insertBefore', () => { }) describe('session creation and Workspace membership', () => { + it('notifies the permission owner only while the confirmed reuse target remains eligible', async () => { + const refreshDefaultForReuse = vi.fn<(session: Session) => void>() + const { api, ctx, root } = await harness(undefined, undefined, { refreshDefaultForReuse }) + const workspace = expectOk(await api.workspace.create(request({ + path: stageDir(root, 'permission-refresh'), + }))).workspace + const reusedId = SessionId('session-reused-blank') + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: reusedId, + }))) + expect(refreshDefaultForReuse).not.toHaveBeenCalled() + + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: reusedId, + reuseWorkspaceBlank: true, + }))) + expect(refreshDefaultForReuse).toHaveBeenCalledOnce() + expect(refreshDefaultForReuse.mock.calls[0]?.[0].id).toBe(reusedId) + + const reused = ctx.sessions.get(reusedId) + if (reused === undefined) throw new Error('reused session was not published') + reused.append('turn/start', { turn: 1 }) + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: reusedId, + reuseWorkspaceBlank: true, + }))) + expect(refreshDefaultForReuse).toHaveBeenCalledOnce() + + const archivedId = SessionId('session-archived-blank') + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: archivedId, + }))) + expectOk(await api.workspace.archiveSession(request({ sessionId: archivedId }))) + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: archivedId, + reuseWorkspaceBlank: true, + }))) + expect(refreshDefaultForReuse).toHaveBeenCalledOnce() + + const nonMemberId = SessionId('session-non-member-blank') + expectOk(await api.sessions.create(request({ + cwd: workspace.path, + sessionId: nonMemberId, + }))) + expectOk(await api.sessions.create(request({ + workspaceId: workspace.workspaceId, + sessionId: nonMemberId, + reuseWorkspaceBlank: true, + }))) + expect(refreshDefaultForReuse).toHaveBeenCalledOnce() + }) + it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { const { api, ctx, root } = await harness() const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..4f560ab511 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -193,8 +193,16 @@ describe('sessions domain schemas', () => { })).toThrow() expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w') // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects. - expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1') + expect(sessionCreateRequestSchema.parse({ + workspaceId: 'w1', sessionId: 's1', reuseWorkspaceBlank: true, + }).reuseWorkspaceBlank).toBe(true) expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/) + expect(() => sessionCreateRequestSchema.parse({ + workspaceId: 'w1', reuseWorkspaceBlank: true, + })).toThrow(/requires workspaceId and sessionId/) + expect(() => sessionCreateRequestSchema.parse({ + sessionId: 's1', reuseWorkspaceBlank: true, + })).toThrow(/requires workspaceId and sessionId/) expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3) expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 3ed38b3d77..a9b51c0ab6 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -80,6 +80,9 @@ { "path": "../../interaction/commands" }, + { + "path": "../../interaction/permission-presets" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 8825fe2aba..d5b49532f4 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md -README.md: a21acdbf3759e2af49e1f0a8993c1552d265a588 -README.zh.md: 76955aee7ed1eb649ab6fa138587b0229697c0f3 +README.md: 7e993ece949aa54d0810d8a7434d3e74d86f4797 +README.zh.md: 0c107ddaaae37e158e2fcff205bd9585d4c4e6ae diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index a21acdbf37..7e993ece94 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -4,17 +4,17 @@ English | [中文](README.zh.md) User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. -`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session. Later changes never alter a session that has started a turn, but reusable blank placeholders that still carry the previous default advance to the new default so Web New Session reuse stays aligned with the setting. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. +The service owns the `permission` Settings namespace. Its `defaultPreset` initializes fresh sessions and default-origin Workspace blanks that Web explicitly confirms for New Session reuse: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. Session creation reads the current setting and pins `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records whether it came from the default, an explicit selection, or legacy-knob inference. A committed setting change does not scan or rewrite existing sessions. When the Web workspace runtime selects a member blank with matching cwd that is not archived, the host rechecks those facts, adopts that exact session, including a cold persisted one, and advances it only when it has not started a turn, its latest selection is default-origin, and its effective knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, independently changed knobs, and ordinary seeded resumes remain pinned. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). -Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. +Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. ## Model Experience -Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only. +Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. #### KV Cache effect @@ -25,4 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. -- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset. +- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset. diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 76955aee7e..0c107ddaaa 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -4,17 +4,17 @@ 通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 -`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中。后续变更不会改变已经开始过轮次的会话,但仍可复用、且还保持旧默认值的空白占位会话会推进到新默认值,从而让 Web 新会话复用继续与设置一致。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 会初始化新建会话,以及 Web 明确确认为新会话复用目标、且权限来自默认值的 Workspace 空白会话:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。创建会话时会读取当前设置,并固定 `permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实还会记录它来自默认值、显式选择还是旧旋钮推断。已提交的设置变更不会扫描或改写现有会话。当 Web workspace runtime 选中 cwd 匹配、属于该 Workspace 且未归档的空白会话时,host 会重新检查这些事实,并接纳这个确切会话(包括冷存储中的持久会话),而且只在它尚未开始轮次、最近选择来自默认值且有效旋钮仍匹配该选择时推进默认值。显式选择、由旧旋钮推断或没有来源标记的旧选择、独立变更的旋钮,以及普通 seed 恢复都会继续固定原权限。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 -两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 ## 模型体验 -间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。 +间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。 #### KV Cache 影响 @@ -25,4 +25,4 @@ - **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 - **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。 -- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。 +- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 9f8223abc7..2ad9f6d7e0 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -42,12 +42,15 @@ declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** - * Records the selected preset as durable, log-only user intent. The knob + * Records the selected preset and whether it came from the session + * default, an explicit selection, or legacy-knob inference. The knob * events follow in the same turn and control execution; this event stays * out of the model transcript and lets {@link effectivePermissionPreset} - * preserve a selection when bundles match. + * preserve a selection when bundles match. `origin` is optional so logs + * written before origin tracking remain readable but are never mistaken + * for refreshable defaults. */ - 'permission/preset': { preset: string } + 'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' } } } @@ -69,7 +72,7 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' -/** Settings namespace carrying the default for future sessions. */ +/** Settings namespace carrying the default for fresh sessions and confirmed reusable blanks. */ export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') /** @@ -130,19 +133,18 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } -/** - * Whether a live session is still a fresh Web New Session placeholder. This - * mirrors the host blank rule (no `turn/start`) but excludes constructor - * seeds, which preserve their effective permissions instead of following - * later defaults. - */ -function isUnseededBlankSession(events: readonly SessionEvent[]): boolean { - return !events.some(event => event.type === 'turn/start' || event.type === 'session/end-seed') +/** Last recorded permission selection, including its durable origin. */ +function latestPermissionSelection(events: readonly SessionEvent[]): Extract | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index] as SessionEvent + if (event.type === 'permission/preset') return event + } + return undefined } /** User setting resolved when a new session receives its initial permission. */ export interface PermissionSettings { - /** Preset pinned into a newly created session. */ + /** Preset pinned into a fresh session or an eligible confirmed blank reuse. */ defaultPreset: string } @@ -155,8 +157,9 @@ export interface Config { */ presets?: Record /** - * Default for new sessions. When omitted, the preset matching the composed - * sandbox and approval defaults is used. + * Default for fresh sessions and eligible confirmed blank reuse. When + * omitted, the preset matching the composed sandbox and approval defaults + * is used. */ defaultPreset?: string } @@ -191,7 +194,6 @@ export class PermissionPresetService extends Service { private readonly presets: Record private defaultSettings: () => PermissionSettings - private activeDefaultPreset: string constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -211,7 +213,6 @@ export class PermissionPresetService extends Service { this.resolve(defaultPreset) const baseSettings: PermissionSettings = { defaultPreset } this.defaultSettings = () => baseSettings - this.activeDefaultPreset = defaultPreset const presetChoices = this.names.map((name) => { const choice = z.const(name) const label = this.presets[name]?.name @@ -224,7 +225,7 @@ export class PermissionPresetService extends Service { setSource: (current) => { this.defaultSettings = current }, - onChange: () => { this.syncBlankSessionsToDefault() }, + onChange: () => {}, }) ctx.on('session/created', (session) => { @@ -280,7 +281,9 @@ export class PermissionPresetService extends Service { if (!this.names.includes(name)) { return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` } } - this.apply(agent.session, name, (policy) =>{ this.ctx.approval.setPolicy(agent, policy) }) + this.apply(agent.session, name, (policy) => { + this.ctx.approval.setPolicy(agent, policy) + }, 'selection') return { kind: 'success', text: `preset ${name}` } }, }) @@ -296,7 +299,7 @@ export class PermissionPresetService extends Service { } /** - * The preset currently selected as the default for future sessions. + * The preset currently selected for fresh sessions and confirmed blank reuse. * @returns the resolved settings value, or the composition default without * a mounted settings provider. */ @@ -315,6 +318,26 @@ export class PermissionPresetService extends Service { return this.derive(foldKnobs(events)) } + /** + * Advance one blank session after the host has confirmed it as the exact + * Web New Session reuse target. Only a still-effective + * default-origin selection advances; a started session, an explicit pick, + * legacy origin-less data, or independently changed knobs remain pinned. + * This is the permission-side half of the Web candidate selection and the + * host's blankness, membership, cwd, and archive verification. + * @param session - the live session selected for Workspace blank reuse. + */ + refreshDefaultForReuse(session: Session): void { + const events = session.events + if (events.some(event => event.type === 'turn/start')) return + const selected = latestPermissionSelection(events) + if (selected?.data.origin !== 'default') return + if (this.current(events) !== selected.data.preset) return + this.apply(session, this.defaultPreset, (policy) => { + setApprovalPolicy(session, policy) + }, 'default') + } + /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */ private derive(state: KnobState): string { const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode @@ -383,14 +406,21 @@ export class PermissionPresetService extends Service { * @param name - the preset to switch to; unknown names throw. */ set(session: Session, name: string): void { - this.apply(session, name, (policy) =>{ setApprovalPolicy(session, policy) }) + this.apply(session, name, (policy) => { + setApprovalPolicy(session, policy) + }, 'selection') } /** Apply one preset with the caller-selected live or initialization policy writer. */ - private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void { + private apply( + session: Session, + name: string, + setApproval: (policy: ApprovalPolicy) => void, + origin: 'default' | 'selection' | 'inferred', + ): void { const spec = this.resolve(name) if (this.current(session.events) !== name) { - session.append('permission/preset', { preset: name }) + session.append('permission/preset', { preset: name, origin }) } const events = session.events if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) { @@ -401,20 +431,6 @@ export class PermissionPresetService extends Service { } } - /** Advance reusable blank sessions that still carry the previous default. */ - private syncBlankSessionsToDefault(): void { - const previous = this.activeDefaultPreset - const next = this.defaultPreset - this.activeDefaultPreset = next - if (next === previous) return - this.resolve(next) - for (const session of this.ctx.sessions.list()) { - if (!isUnseededBlankSession(session.events)) continue - if (this.current(session.events) !== previous) continue - this.set(session, next) - } - } - /** * Fill every missing permission fact before a session is published. A * genuinely fresh session uses the current user default; seeded or partially @@ -430,7 +446,7 @@ export class PermissionPresetService extends Service { if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { const name = this.defaultPreset const spec = this.resolve(name) - session.append('permission/preset', { preset: name }) + session.append('permission/preset', { preset: name, origin: 'default' }) setSandboxMode(session, spec.sandbox) setApprovalPolicy(session, spec.approval) return @@ -443,7 +459,7 @@ export class PermissionPresetService extends Service { } const effective = this.derive(state) if (selected === undefined && effective !== CUSTOM_PRESET) { - session.append('permission/preset', { preset: effective }) + session.append('permission/preset', { preset: effective, origin: 'inferred' }) } if (sandbox === undefined) { setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode) diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index e0f9dde956..222e609ce0 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -131,7 +131,7 @@ describe('PermissionPresetService', () => { const session = freshSession('sess-set') ctx.permissionPresets.set(session, 'danger-full-access') expect(session.events.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) @@ -154,7 +154,7 @@ describe('PermissionPresetService', () => { ctx.permissionPresets.set(session, 'danger-full-access') const tail = session.events.slice(4) expect(tail.map(e => [e.type, e.data])).toEqual([ - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }], ['sandbox/mode', { mode: 'danger-full-access' }], ]) }) @@ -197,7 +197,7 @@ describe('new-session default', () => { const ctx = await mountedStore() const first = ctx.sessions.create(SessionId('first')) expect(first.events.map(event => [event.type, event.data])).toEqual([ - ['permission/preset', { preset: 'workspace-write' }], + ['permission/preset', { preset: 'workspace-write', origin: 'default' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], ]) @@ -206,6 +206,7 @@ describe('new-session default', () => { await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { defaultPreset: 'danger-full-access', }) + ctx.permissionPresets.refreshDefaultForReuse(first) expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access') const second = ctx.sessions.create(SessionId('second')) expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write') @@ -215,7 +216,7 @@ describe('new-session default', () => { ]) }) - it('advances reusable blank sessions that still carry the previous default', async () => { + it('advances a confirmed reusable blank session that still carries its default', async () => { const ctx = await mountedStore() const blank = ctx.sessions.create(SessionId('blank-placeholder')) expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write') @@ -223,19 +224,21 @@ describe('new-session default', () => { await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { defaultPreset: 'danger-full-access', }) + expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write') + ctx.permissionPresets.refreshDefaultForReuse(blank) expect(ctx.permissionPresets.current(blank.events)).toBe('danger-full-access') expect(blank.events.map(event => [event.type, event.data])).toEqual([ - ['permission/preset', { preset: 'workspace-write' }], + ['permission/preset', { preset: 'workspace-write', origin: 'default' }], ['sandbox/mode', { mode: 'workspace-write' }], ['approval/policy', { policy: 'ask' }], - ['permission/preset', { preset: 'danger-full-access' }], + ['permission/preset', { preset: 'danger-full-access', origin: 'default' }], ['sandbox/mode', { mode: 'danger-full-access' }], ['approval/policy', { policy: 'never' }], ]) }) - it('leaves blank sessions with an explicit pick or a constructor seed unchanged', async () => { + it('leaves explicit, inferred, legacy, absent, and independently changed selections unchanged', async () => { const ctx = await mountedStore() const picked = ctx.sessions.create(SessionId('blank-explicit-pick')) ctx.permissionPresets.set(picked, 'danger-full-access') @@ -245,12 +248,55 @@ describe('new-session default', () => { expect(ctx.permissionPresets.current(restored.events)).toBe('workspace-write') const restoredEvents = [...restored.events] + const drifted = ctx.sessions.create(SessionId('blank-drifted-knob')) + drifted.append('sandbox/mode', { mode: 'read-only' }) + const driftedEvents = [...drifted.events] + + const legacy = freshSession('blank-originless-selection') + legacy.append('permission/preset', { preset: 'workspace-write' }) + legacy.append('sandbox/mode', { mode: 'workspace-write' }) + legacy.append('approval/policy', { policy: 'ask' }) + const legacyEvents = [...legacy.events] + + const absent = freshSession('blank-without-selection') + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { defaultPreset: 'danger-full-access', }) + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'workspace-write', + }) + ctx.permissionPresets.refreshDefaultForReuse(picked) + ctx.permissionPresets.refreshDefaultForReuse(restored) + ctx.permissionPresets.refreshDefaultForReuse(drifted) + ctx.permissionPresets.refreshDefaultForReuse(legacy) + ctx.permissionPresets.refreshDefaultForReuse(absent) expect(picked.events).toEqual(pickedEvents) expect(restored.events).toEqual(restoredEvents) + expect(drifted.events).toEqual(driftedEvents) + expect(legacy.events).toEqual(legacyEvents) + expect(absent.events).toEqual([]) + }) + + it('refreshes a cold default-origin placeholder after resume', async () => { + const ctx = await mountedStore() + const source = ctx.sessions.create(SessionId('cold-placeholder-source')) + const stored = [...source.events] + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const resumed = ctx.sessions.create(SessionId('cold-placeholder-resumed'), { seed: stored }) + expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write') + + ctx.permissionPresets.refreshDefaultForReuse(resumed) + + expect(ctx.permissionPresets.current(resumed.events)).toBe('danger-full-access') + expect(resumed.events.slice(-3).map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'danger-full-access', origin: 'default' }], + ['sandbox/mode', { mode: 'danger-full-access' }], + ['approval/policy', { policy: 'never' }], + ]) }) it('preserves a seeded legacy session instead of applying the latest user default', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6123468bef..94b43f3335 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4884,6 +4884,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-permission-presets': + specifier: workspace:^ + version: link:../../interaction/permission-presets '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../../storage/storage