Merge master and regenerate plugin manager translation record

This commit is contained in:
fz committed 2026-10-29 11:51:38 +08:00
commit c2b668ccd8
1469 files changed
+81202 -4545

No files matched your search

@@ -9,8 +9,8 @@
en: edefff7cd986a509
zh: a270839fa86976a2
/agent-note-guided-plugin-installation/decision:
en: 5448b9f8aaa9a569
zh: 9024c5328c5da3bd
en: ca0224ae08a4b383
zh: 780603ca24487949
/agent-note-guided-plugin-installation/alternatives-considered:
en: be64b541f4d81e7c
zh: 8600531c08ab4288
@@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../../../../packages/boot/plugin-manager/README.md)). The client shows the kind as one line and folds pnpm's output behind the details, which also name the complete log and a failure that stopped before pnpm ran ([details](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.md)). Parsing the log is confined to this one function with a fixture-driven test.
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
@@ -14,7 +14,7 @@ Status: implemented
**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../../../../packages/boot/plugin-manager/README.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情;详情还会指出完整日志,以及尚未运行 pnpm 就停下的一次失败([详情](../bug-fix/2026-10-28-plugin-install-failure-diagnostics.zh.md))。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
@@ -9,8 +9,8 @@
en: 386864a282e5ad58
zh: a4ea75ec70eb7778
/agent-note-shared-native-cua-process-with-agent-scoped-state/decision:
en: ebf231a6aad109bb
zh: 53b553e679569da1
en: 11bab6870c3d802f
zh: a22114f2217a3a41
/agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered:
en: 883f71387f599160
zh: 4b5f1450be799397
@@ -12,6 +12,8 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat
One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service.
The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures.
Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization.
Cancellation and timeout first stop one request and wait for acknowledgement. A timed-out screenshot retains the single system-RPC slot until the real callback completes; new screenshots fail with `CAPTURE_BUSY` instead of accumulating requests. Late pixels are discarded, while other AX and input operations remain available. Ending an await or creating a wrapper does not prove that the system connection has recovered. An operation or cleanup that cannot stop can invalidate the process, making every old handle unusable. Uncertain input is never replayed automatically.
@@ -12,6 +12,8 @@ Status: implemented
同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。
服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。
每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。
取消或超时先停止单次请求并等待确认。截图超时后,唯一的系统 RPC 槽位保留到真实回调结束;新截图返回 `CAPTURE_BUSY`,不继续堆积请求。迟到图片被丢弃,其他 AX 与输入操作仍可执行。结束 await 或创建包装对象不能证明系统连接已恢复。操作或清理无法停止时,才可能使进程失效,届时所有旧句柄均不可用。结果不确定的输入不会自动重放。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-21-context-menu-contributions.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-context-menu-contributions.md
/agent-note-context-menu-contributions:
en: 380bed76b1989558
zh: ecbb5c15933431f6
/agent-note-context-menu-contributions/problem:
en: baf7f6e331fd9f03
zh: 8a45faaad34e6b0c
/agent-note-context-menu-contributions/decision:
en: f04341778de57140
zh: 12147060ea28feb1
/agent-note-context-menu-contributions/alternatives-considered:
en: 1fc419cf3a0642d3
zh: f774e64679bed2ab
/agent-note-context-menu-contributions/consequences:
en: 7a55e405714d598f
zh: d7ff74ace672bf22
@@ -0,0 +1,29 @@
# Agent Note: Context menu contributions
Status: implemented
English | [中文](2026-10-21-context-menu-contributions.zh.md)
## Problem
Session and tab menus already accept React Slot entries. Reusing only those entries would require external plugins to supply React components and would leave action discovery, target capture, and cancellation coupled to each trigger.
## Decision
[Context menus](../../../../packages/client/ui-context-menu/README.md) own typed target families, contribution resolution, and callback lifetime. Feature plugins register actions through the same service available to external Client plugins. Plain presentation fields let a renderer consume resolved actions without loading contributed React components. Existing Session and tab Slots remain in feature-owned adapters, preserving those extension positions.
[File actions](../../../../packages/client/ui-file-actions/README.md) own original-content transfer separately from menus and image components. Saving retains original encoding; clipboard interoperability may require PNG conversion. A browser download supplies no local path, so reveal requires a verified Host file or the destination returned by a native save.
The menu plugin contributes `useContextMenu` through the existing Slot root binding. Its ordinary `getTriggerProps` factory centralizes DOM gestures while business owners still provide captured targets. Plain children receive props or existing delegates; a separate React Context or utility package would duplicate the established lifetime and injection mechanism.
## Alternatives considered
**React Slots alone.** They preserve arbitrary visual content but cannot supply a presentation-independent action model. They remain available for existing component contributions.
**A central switch over built-in target kinds.** It would make each new plugin-owned target require changes to the menu implementation. Declaration merging and registered providers instead keep target-specific behavior with its owner.
**Native menus first.** It would duplicate the existing keyboard, placement, and dismissal implementation while excluding component-only legacy entries. The shared Web renderer serves Desktop too; another renderer can consume the plain action view independently.
## Consequences
An opening captures its target and contribution set. Dismissal cancels metadata work; unloading withdraws that provider's actions. Selected actions own execution and must recheck mutable resources. Legacy React entries are absent from plain-data resolution, so native rendering cannot assume complete parity. These lifetimes are covered by registry tests; browser scenarios cover unchanged selection, original bytes, and nested-overlay dismissal.
@@ -0,0 +1,29 @@
# Agent Note: 右键菜单贡献
Status: implemented
[English](2026-10-21-context-menu-contributions.md) | 中文
## 问题
Session 和标签页菜单已经支持 React Slot 条目。仅复用这些条目会要求外部插件提供 React 组件,并使动作发现、目标捕获和取消逻辑继续耦合到各个触发位置。
## 决策
[右键菜单](../../../../packages/client/ui-context-menu/README.zh.md)负责类型化目标类别、贡献解析和回调生命周期。功能插件通过与外部 Client 插件相同的服务注册动作。纯数据展示字段让渲染器无需加载贡献方的 React 组件即可消费已解析动作。现有 Session 和标签页 Slot 保留在各功能所属的适配器中,从而保留这些扩展位置。
[文件动作](../../../../packages/client/ui-file-actions/README.zh.md)独立于菜单和图片组件负责原始内容传输。保存保留原始编码;剪贴板互操作可能需要转换为 PNG。浏览器下载不提供本机路径,因此在文件夹显示要求已验证的 Host 文件,或原生保存返回的目标路径。
菜单插件通过现有 Slot root 绑定贡献 `useContextMenu`。普通函数 `getTriggerProps` 统一处理 DOM 手势,业务所有者继续提供捕获的目标。普通子组件通过 props 或现有 delegate 接收能力;独立 React Context 或工具包会重复已有生命周期与注入机制。
## 考虑过的替代方案
**仅使用 React Slot。** 它们支持任意视觉内容,但无法提供独立于展示方式的动作模型。现有组件贡献仍可使用它们。
**在中心位置按内置目标类型分支。** 这种方式会使每个新增的插件目标都要求修改菜单实现。声明合并与注册式提供方让目标特有行为保留在其所属插件。
**优先实现原生菜单。** 这会重复已有的键盘、定位和关闭实现,同时无法容纳仅提供组件的旧条目。Desktop 同样使用共享 Web 渲染器;其他渲染器可以独立消费纯数据动作视图。
## 影响
打开菜单时捕获目标和贡献集合。关闭取消元数据任务;插件卸载撤回该提供方的动作。已选动作自行负责执行,并须重新检查可变资源。旧 React 条目不参与纯数据解析,因此原生渲染不能假定功能完全一致。注册表测试覆盖这些生命周期;浏览器场景覆盖选择不变、原始字节以及嵌套浮层关闭。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-28-forge-git-session-permissions.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-forge-git-session-permissions.md
/agent-note-session-permissions-for-forge-git-queries:
en: a06c42342d1f0bad
zh: 88b063883336cb0b
/agent-note-session-permissions-for-forge-git-queries/problem:
en: ee1e5ba8190a9419
zh: 88d6c35a3a400eb0
/agent-note-session-permissions-for-forge-git-queries/decision:
en: 77d7706b2577e552
zh: a4538a19656d5945
/agent-note-session-permissions-for-forge-git-queries/alternatives-considered:
en: 69f0bbbc412a546f
zh: 9527e87b5adfc7c3
/agent-note-session-permissions-for-forge-git-queries/consequences:
en: acf5249a959ac14c
zh: 268fc51ec3278dcb
@@ -0,0 +1,31 @@
# Agent Note: Session permissions for Forge Git queries
Status: implemented
English | [中文](2026-10-28-forge-git-session-permissions.zh.md)
## Problem
The [Forge panel](../feature/2026-10-09-forge-panel.md) forces read-only confinement even when a Session grants full access. On hosts without a usable sandbox, its checkout and local changes remain unavailable while the Session's shell works. Background queries also reach repository-configured executable helpers through working-tree comparisons.
## Decision
Git inspection uses the Session's resolved sandbox mode and subprocess provider. Full access requires no sandbox backend; confined modes retain their enforcement and never retry unconfined. The Session remains the authority for filesystem permissions, including workspace-write access. Query commands do not request repository mutations.
Local patches and statistics apply command-local Git configuration to disable clean/process filters without changing user configuration. Environment entries preserve subsection names containing equals signs. Existing hook, fsmonitor, external-diff, textconv, optional-lock, and lazy-fetch restrictions remain; submodule queries show commit changes without scanning internal dirty files. Configuration read failure stops the comparison; the changes view reports the filter configuration failure separately from oversized diffs and offers a retry. These restrictions follow the informational-diff approach in Codex while retaining DSH's execution-world and cancellation ownership.
## Alternatives considered
**Force read-only for every query.** This makes an explicitly unconfined Session depend on an unavailable backend. Using the Session mode keeps tool and panel permission behavior consistent.
**Retry without confinement after failure.** This would silently grant authority that a restricted Session does not have.
**Run configured content converters.** They can execute programs and write files during automatic refresh. Disabling them trades converted-content equivalence for predictable query behavior.
## Consequences
Operating-system write protection follows the Session mode rather than a separate panel policy. With clean/process filters disabled, comparisons still apply Git's built-in newline, ident, and encoding conversions: materialized LFS and other filtered files can appear changed even when ordinary Git considers them clean. Pointer-only checkouts compare normally. Uncommitted submodule contents are omitted. The original Forge note remains active for panel ownership and worktree lifecycle; this note supersedes only its unconditional read-only policy.
Filter overrides require Git 2.31 or newer; older Git can ignore them and execute configured helpers under Session permissions. Enumeration and diff run in separate processes, so a concurrently added driver can escape the overrides. These restrictions limit incidental helper execution; the Session sandbox determines the permissions of every process that runs.
Real-repository tests cover permission modes, unavailable confinement, unchanged index/configuration, configured helper markers, submodule commits, filtered bytes, and failed filter enumeration. The Web replay covers permission changes through the picker and the resulting checkout and local-change view.
@@ -0,0 +1,31 @@
# Agent Note: Forge Git 查询的 Session 权限
Status: implemented
[English](2026-10-28-forge-git-session-permissions.md) | 中文
## 问题
[Forge 面板](../feature/2026-10-09-forge-panel.zh.md)即使在 Session 授予完全权限时也强制使用只读沙箱。在没有可用沙箱的主机上,Session 的 shell 可以工作,检出信息与本地变更却持续不可用。后台查询还会通过工作区比较执行仓库配置的辅助程序。
## 决策
Git 检查使用 Session 解析出的沙箱模式与子进程提供方。完全权限无需沙箱后端;受限模式保留强制约束,绝不以无沙箱方式重试。Session 统一决定文件系统权限,包括工作区写入权限。查询命令不请求修改仓库。
本地补丁与统计应用命令级 Git 配置以禁用 clean/process filter,不修改用户配置。环境项保留包含等号的子节名称。现有钩子、fsmonitor、外部 diff、textconv、可选锁与延迟获取限制继续生效;子模块查询展示提交变化,不扫描内部脏文件。配置读取失败时停止比较;变更视图区分过滤器配置失败与 diff 过大,并提供重试。这些限制参考 Codex 的信息展示型 diff,同时保留 DSH 对执行环境与取消过程的管理。
## 考虑过的替代方案
**所有查询强制只读。** 这让已明确取消限制的 Session 依赖不可用的后端。使用 Session 模式使工具与面板的权限行为一致。
**失败后无沙箱重试。** 这会静默授予受限 Session 原本没有的权限。
**运行已配置的内容转换器。** 它们可能在自动刷新时执行程序并写入文件。禁用它们以放弃转换后内容等价性为代价,换取可预测的查询行为。
## 后果
操作系统的写入保护跟随 Session 模式,而非独立的面板策略。禁用 clean/process filter 后,比较仍应用 Git 内置的换行、ident 与编码转换:已物化的 LFS 文件及其他过滤文件可能显示为变更,即使普通 Git 认为它们未变。仅保留指针的检出可正常比较。子模块内部未提交内容不显示。原 Forge 记录仍保留面板所有权与工作树生命周期的决策;本记录仅替代其无条件只读策略。
过滤器覆盖需要 Git 2.31 或更新版本;旧 Git 可能忽略覆盖,按 Session 权限执行已配置的辅助程序。枚举与 diff 在独立进程中执行,因此并发新增的驱动可能绕过覆盖。这些限制减少附带的辅助程序执行;所有实际运行进程的权限由 Session 沙箱决定。
真实仓库测试覆盖权限模式、沙箱不可用、索引与配置不变、辅助程序标记、子模块提交、过滤文件字节和过滤器枚举失败。Web 回放覆盖通过选择器切换权限及相应的检出信息与本地变更视图。
@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-28-plugin-install-failure-diagnostics.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-28-plugin-install-failure-diagnostics.md
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog:
en: dc13db49685fee98
zh: 65896465ceaba16a
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/problem:
en: 34c85dbc0fdaeeaf
zh: b11042edb1eb8e3b
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/decision:
en: fa3f10f9d7502077
zh: c6b83153a0f5e324
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/alternatives-considered:
en: 2616d060183a7a74
zh: 15179fd42ea2b6dc
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/consequences:
en: 246d2a60f856c631
zh: d7a552af3ad32b6f
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/related:
en: e0709383c16148e9
zh: 00765408d0ad25f1
/agent-note-install-failures-keep-their-diagnostics-in-the-dialog/testing:
en: 336df2ffde183519
zh: 86e62a8a052c1f91
@@ -0,0 +1,47 @@
# Agent Note: Install failures keep their diagnostics in the dialog
Status: implemented
English | [中文](2026-10-28-plugin-install-failure-diagnostics.zh.md)
## Problem
A failed plugin installation could show the generic sentence "Something went wrong during the install; the details say what" while its expanded details showed only "No output". The dialog rendered the live install-log stream and the failure kind, and the two Host diagnostics that explain such a failure never entered that stream. A pnpm run that printed nothing had its diagnostic (`result.shortMessage`, or `pnpm failed` as the fallback) written to the complete log and the returned result but never streamed, so the dialog's terminal stayed empty. The GitHub connection check that stops an installation before pnpm runs kept its captured output only in `packageResult.output`. The Client also ignored the settled result's `packageResult.logPath` and `stage`, so a failure with recorded diagnostics offered no path to the complete log and could not say that a finished installation failed while the bundle was being activated.
## Decision
**The install-log stream carries the Host diagnostic of every run the dialog shows.** A pnpm run that fails without printing anything streams the diagnostic the Host records for it, so the complete log and the dialog show the same text. The GitHub connection check reports the `git … ls-remote` command line it ran, with the user information of an http(s) repository URL removed; when its network failure or timeout stops the installation, `installBundle` emits that command, the captured output, and the failure's exit status as the installation's only install-log run under the caller's request id. A check failure the installation survives streams nothing. A timed-out check's Git returned no exit status, so that run settles without one; the returned result records the coerced status 1, because `PackageResult.exitCode` is a number, and `PackageResult.timedOut` states that the status is the one the failure is read as. The command line itself stays module-local: `installBundle` strips it off the plain `PackageResult` it returns, so no second wire field appears.
**The failed screen keeps what the settled result reports, not only the live stream.** `InstallState` holds the settled `packageResult.logPath` — the run that settled the dialog, on the Host's filesystem — and the details name it as the complete log. `InstallState['failure'].stage` and `ChangeResult.enabled` add the sentence that the installation finished and enabling the bundle failed, only when that change did ask to enable it; an install that left activation to **Enable now** never claims it failed enabling. Details on a settled screen without a streamed run say the dialog received no terminal output, never that the run printed none, and a running install makes no such claim. The generic sentence names the failed install command, except when the failed step is applying an enabled bundle, where the enabling sentence stands alone. The answer and the log travel over separate transports, so a run whose first chunk arrives after the answer is kept in the settled dialog as a settled run rather than dropped or shown as running.
No wire type, public export, or config key is added: `ChangeResult.packageResult.logPath`, `ChangeResult.stage`, `ChangeResult.enabled`, and `packageResult.timedOut` already carry these facts to every consumer. The check's command line is a module-local `GithubConnectionFailure` in `github-connection.ts`.
## Alternatives considered
**Have the Client synthesize a terminal run from `packageResult.output`.** Rejected: the install-log stream would stay incomplete for every other reader, including the agent tool and the complete Host log, and the dialog would carry two notions of a run — one streamed, one rebuilt from the result.
**Render `packageResult.output` as a separate Host-diagnostic block.** Rejected: it duplicates the terminal surface with a second styling and copy path, and the failing check's command line would still be missing.
**Add a failure-stage field to `ChangeResult`.** Rejected: `stage` already names installation and activation, and only activation needed its own sentence; a second stage vocabulary would make every consumer learn two fields for one fact.
**Keep the generic sentence and rely on the details.** Rejected: the empty details panel this fixes was exactly what the sentence pointed at, so the sentence must not promise diagnostics the dialog does not show.
**Report the check's coerced exit code in the streamed run too.** Rejected: the stream describes what the process did, and a Git the deadline killed returned no status; the result keeps the coerced status only because its exit code is a number, which the JSDoc states.
## Consequences
- A failed installation always offers something to act on: a pnpm diagnostic for a silent failure, the failing Git command and its output for a GitHub connection failure, the complete log of the settled run, and a sentence naming the failed step when an enabled installation could not be applied.
- The generic sentence becomes "The install command failed; the details say why" in every dictionary; the details' no-run note says the dialog received no terminal output.
- A GitHub connection failure reports `timedOut` and returns the coerced exit status 1, while its streamed run settles without an exit code; the displayed command line omits URL user information, and no `argv` field joins the Remote answer.
- The details' log path covers the run that settled the dialog and is a Host path, so earlier registry attempts and a remote browser may not reach it.
- No new configuration, wire field, or public package export.
## Related
[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.md) owns the install dialog's screens, the pre-install spec check, and the failure classification; this note extends its failed screen and install-log stream.
## Testing
- `packages/boot/plugin-manager/tests/operations.spec.ts` pins the no-output diagnostic stream; `github-connection.spec.ts` pins the recorded command line without URL user information and the timeout state, including the coerced status in the result; `manager.spec.ts` pins the single streamed check run under the request id, its absent exit code on timeout, that the returned `packageResult` carries no command line, and that a check failure pnpm may resolve streams nothing.
- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` pins the settled log path, stage and enablement fact, the run kept when its first chunk arrives after the answer, and the reset on retry; `components.client.spec.tsx` pins the enabling sentence (only for an install that asked to enable), the complete-log line, and the settled no-output sentence.
- `apps/web/tests/plugin-install-github.e2e.ts` expands the details of a real GitHub connection failure and finds the Git command, its output, and the log path; `apps/web/tests/plugin-install-silent-failure.e2e.ts` fails a real installation through a package manager that prints nothing and finds the run's command line, its diagnostic, and the complete log in the details.
@@ -0,0 +1,47 @@
# Agent Note: Install failures keep their diagnostics in the dialog
Status: implemented
[English](2026-10-28-plugin-install-failure-diagnostics.md) | 中文
## Problem
一次插件安装失败时,界面可能显示通用的“安装过程中出错,原因见安装详情”,而展开后的详情只有“无输出”。对话框渲染的是实时的 install-log 流和失败 kind,而解释这类失败的 Host 诊断从未进入该流。打印了空输出的 pnpm 运行,其诊断(`result.shortMessage`,缺省为 `pnpm failed`)只写进完整日志和返回结果,从未被流式发送,因此对话框的终端一直是空的。在 pnpm 运行前就中止安装的 GitHub 连接检查,其捕获的输出只留在 `packageResult.output` 里。客户端同时也忽略了已结束结果中的 `packageResult.logPath` 和 `stage`,所以即使诊断已被记录,失败界面也没有指向完整日志的入口,也无法说明安装其实已经完成、失败发生在启用 bundle 时。
## Decision
**install-log 流携带对话框所展示的每一次运行的 Host 诊断。** 没有打印任何内容就失败的 pnpm 运行,会把 Host 为其记录的那条诊断流式发送出来,于是完整日志和对话框显示同样的文本。GitHub 连接检查会报告它实际执行的 `git … ls-remote` 命令行,并去掉 http(s) 仓库 URL 中的用户信息;当它的网络失败或超时中止安装时,`installBundle` 把该命令、捕获的输出和这次失败的退出状态作为该次安装唯一的 install-log 运行、以调用方的 request id 发出。安装仍能继续进行的检查失败不发送任何内容。超时的检查里 Git 没有返回退出状态,所以该运行不带退出码结束;返回结果记录强制填入的状态 1,因为 `PackageResult.exitCode` 是数字,而 `PackageResult.timedOut` 说明该状态只是失败被读取成的状态。命令行本身仍只在模块内:`installBundle` 在返回的普通 `PackageResult` 上剥掉它,因此不会多出 wire 字段。
**失败界面保留已结束结果所报告的内容,而不只是实时流。** `InstallState` 持有已结束的 `packageResult.logPath`——结束对话框的那次运行,位于 Host 文件系统——详情把它标为完整日志。`InstallState['failure'].stage` 与 `ChangeResult.enabled` 补充一句话:安装已完成,启用 bundle 时失败;只有当这次变更确实要求启用时才出现,把启用留给**立即启用**的安装不会声称启用失败。已结束界面里没有任何流式运行的详情会说明对话框没有收到终端输出,而不会断言运行本身没有输出;安装进行中也不会这样说。通用提示句点明失败的是安装命令,但失败步骤是应用已启用的 bundle 时,只使用启用阶段的那句话。安装应答与日志走不同传输,因此首个 chunk 晚于应答到达的运行会作为已结束的运行保留在已结束的对话框里,既不会被丢弃,也不会显示成运行中。
不新增 wire 类型、公开导出或配置键:`ChangeResult.packageResult.logPath`、`ChangeResult.stage`、`ChangeResult.enabled` 和 `packageResult.timedOut` 本来就把这些事实带给每个消费方。检查的命令行是 `github-connection.ts` 内模块级的 `GithubConnectionFailure`。
## Alternatives considered
**让客户端从 `packageResult.output` 合成一次终端运行。** 否决:对其他读取方(agent 工具和完整的 Host 日志)来说 install-log 流仍然不完整,而且对话框会出现两种“运行”的概念——一种来自流,一种从结果重建。
**把 `packageResult.output` 渲染成单独的 Host 诊断块。** 否决:它会以第二套样式和文案路径重复终端界面,而且失败检查的命令行仍然缺失。
**给 `ChangeResult` 新增失败阶段字段。** 否决:`stage` 已经区分安装和启用,而且只有启用需要自己的提示句;第二套阶段词汇会让每个消费方为一个事实学两个字段。
**保留通用提示句、只依赖详情。** 否决:这次要修的正是被该提示句指向的空详情面板,因此提示句不能承诺对话框并不显示的诊断。
**在流式运行里也报告检查被强制的退出码。** 否决:流描述的是进程实际做了什么,而被期限终止的 Git 没有返回任何状态;结果保留强制状态只是因为它的退出码字段是数字,JSDoc 已写明这一点。
## Consequences
- 安装失败后总有可行动的信息:静默失败时的 pnpm 诊断、GitHub 连接失败时出错的 Git 命令及其输出、结束对话框那次运行的完整日志,以及已启用的安装在应用阶段失败时点名失败步骤的一句话。
- 通用提示句在所有词典中变为“安装命令失败,原因见安装详情”;详情里的无运行提示改为说明对话框没有收到终端输出。
- GitHub 连接失败会报告 `timedOut` 并返回强制状态 1,而它的流式运行不带退出码结束;展示的命令行去掉 URL 用户信息,Remote 应答里也不会多出 `argv` 字段。
- 详情里的日志路径只覆盖结束对话框的那次运行,且是 Host 路径,因此更早的换源尝试与远端浏览器可能都够不到它。
- 不新增配置、wire 字段或公开包导出。
## Related
[Guided plugin installation](../architecture/2026-09-15-guided-plugin-installation.zh.md) 拥有安装对话框的各个界面、安装前的 spec 检查和失败分类;本记录扩展了它的失败界面和 install-log 流。
## Testing
- `packages/boot/plugin-manager/tests/operations.spec.ts` 钉住无输出诊断的流式发送;`github-connection.spec.ts` 钉住去掉 URL 用户信息的记录命令行与超时状态(含结果里的强制状态);`manager.spec.ts` 钉住以 request id 发送的唯一检查运行、超时下缺失的退出码、返回的 `packageResult` 不含命令行,以及 pnpm 可能自行解决的检查失败不发送任何运行。
- `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` 钉住已结束的日志路径、stage 与启用事实、首个 chunk 晚于应答到达时保留的运行,以及重试时的重置;`components.client.spec.tsx` 钉住启用阶段的提示句(只对要求启用的安装)、完整日志行,以及已结束界面的无输出提示句。
- `apps/web/tests/plugin-install-github.e2e.ts` 展开一次真实 GitHub 连接失败的详情,找到 Git 命令、它的输出和日志路径;`apps/web/tests/plugin-install-silent-failure.e2e.ts` 让一次真实安装通过不打印任何内容的包管理器失败,并在详情中找到该运行的命令行、诊断和完整日志。
@@ -15,8 +15,8 @@
en: db0a2deb9193a00d
zh: dab5a65b88957464
/agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request:
en: 7bd9fef90e8639b9
zh: e2a096885c9c1e83
en: 45ac7b4e759b29ad
zh: 5374cc89e587f076
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
en: 647fe68c52b907e0
zh: 267a739bad44ad65
@@ -41,6 +41,8 @@ The integration uses only the latest `request/header.config` provider/model and
Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once.
The request also carries the reviewed Session's `sessionId` and `purpose: 'auto-review'`. Routes that require a per-conversation identity or key prompt caching on one, such as the `x-opencode-session` header OpenCode requires and OpenAI `prompt_cache_key`, derive it from that id; without it OpenCode rejects every review, and Auto fails every call. The [DeepSeek Session-log upload](../../../../packages/session/session-log-deepseek/README.md#request-field) applies the same eligibility and registered exclusions to reviews as to other requests. The uploaded data is the canonical Session log; review prompts and decisions remain outside that log.
The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver.
The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
@@ -41,6 +41,8 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi
每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。
请求还携带被评审 Session 的 `sessionId` 与 `purpose: 'auto-review'`。需要按对话标识路由或以其为 prompt 缓存键的路由(例如 OpenCode 要求的 `x-opencode-session` header 与 OpenAI 的 `prompt_cache_key`)由该 id 派生;缺少它时 OpenCode 会拒绝每次评审,Auto 会让每个调用失败。[DeepSeek session-log 上传](../../../../packages/session/session-log-deepseek/README.zh.md#request-field)对评审和其他请求应用相同的上传资格与注册排除条件。上传的是权威 Session 日志;评审提示词和决策仍不进入该日志。
reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。
主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
@@ -0,0 +1,55 @@
# Bilingual-pair consistency record for 2026-09-13-liveshare-protocol-and-owner-surfaces.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.md
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague:
en: e73b246e19c9b412
zh: c87594ad5eccbfb5
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/problem:
en: 17bc15d382139909
zh: 3eff34b5ddea951e
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision:
en: a15c7c13f167e5ac
zh: a15c7c13f167e5ac
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-share-is:
en: ff1dbb0f7c2355e4
zh: 3de92b2e6c5e9e15
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-reader-can-do:
en: 5652ed7f515a2a5d
zh: 993378f78f5a4120
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers:
en: 0b9303333bf482ae
zh: 6dfc68ad9e2924c3
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility:
en: 754705367c350b31
zh: e10b6541458c4a45
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-shared-work-stays-inside-its-grant:
en: fa3ab42895a16ce1
zh: 429be63cfb8e55a6
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/keeping-one-branch-across-retries:
en: 6b0369dafe34a5aa
zh: aea9465216204122
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/who-the-owner-is:
en: 7e6d1305782d2822
zh: 66fb2cbef0f49d47
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-reader-sees-at-the-top:
en: 44d02a91cf0c5715
zh: dabbf6c59ff08e4a
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/when-the-connection-breaks:
en: 36ee6e090797806b
zh: 21809a0cf990d905
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-the-served-page-is-exercised:
en: d9cbb8277862587c
zh: 867fa6c4db95d09c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-package-exposes:
en: d13a0df787f212d4
zh: 24794e8bf4d92492
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/alternatives-considered:
en: 2daeba6e763383e4
zh: fe0fe3f7f4d546e7
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/verification:
en: 76cc786336c5daf5
zh: 98bd2b9cc7d1791c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/consequences:
en: 8a6e06a9fba917e2
zh: 68a9d19b15119345
@@ -0,0 +1,156 @@
# Agent Note: Liveshare — sharing a DSH session with a colleague
Status: implemented
English | [中文](2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)
## Problem
A DSH session runs on your own computer. A colleague sometimes needs to watch one while you work — to follow a debugging session, or to see how a long task is going. Today there is no way to do that. Sending the session log is not the same thing: the colleague would need your plugins, configuration, and sandbox to render it, and the session only makes sense while it is running on your machine.
So a share needs three things at once. A **page the colleague opens in a browser** that shows the session running on your machine, without connecting them to it. A **limit on what that page can do**, starting with "read without writing" — anything the page offers beyond what you agreed is a security hole, not a convenience. And an **answer to "who shared this?"**, because a page that cannot say who its owner is cannot be trusted by whoever opens it.
This note covers the half that runs inside the owner's DSH — the share dialog, the owner-side service behind it, the package's browser face, and the page a reader opens — plus the protocol package both sides speak. The Liveshare server, the shared service in the middle, is its own package (`@deepseek-ai/dsh-experimental-liveshare-relay`) and is reviewed and deployed separately.
## Decision
### What a share is
An owner creates a share for one session from DSH itself. The share is published to the Liveshare server, a service both sides can reach, and DSH returns a link. Opening that link shows the session rendered by the owner's own DSH with its own plugins, so the reader never needs the owner's configuration. The reader's list contains the source Session and any children that reader created through the share. Workspace names, membership, and ordering are trimmed to those Sessions; unrelated Sessions and workspaces stay hidden.
A share created from the DSH Share dialog is an **organization link** share: it carries `accessMode: 'open'`, so anyone in the organization who has the link is admitted. The deployment's `LIVESHARE_ALLOWED_SUBJECTS` is unioned into every share's ACL: it narrows an `open` share to the subjects it lists, and on a stored `selected` record it adds those subjects beside the record's own selection.
**Specific colleagues (`selected`) and the older `approval` spelling are legacy stored records.** The Host still reads them with their recorded ACL and shows their access summary, and the Liveshare server still admits their viewers by that ACL, but the shipped Share dialog offers no way to create one. `liveshareShares.create` keeps its `accessMode` and `allowedSubjectIds` fields for those records and for callers that invoke the Remote directly; the shipped dialog sends neither.
### What a reader can do
The reader's page is the normal DSH client, not a cut-down copy. The sidebar, the session list, expanding, collapsing, and selecting are all there, because browsing is part of reading. What is missing is every action the reader is not allowed to perform: creating an unrelated session, renaming, archiving, deleting, and managing a workspace. Branching follows the share's own fork switch, prompting is available in both tiers and runs inside that tier's sandbox, and attachment upload follows the Sessions the reader can see; management permissions remain independent of the tier and the fork switch.
These actions are not hidden with CSS. One access snapshot decides what the page may do: the remote-access client follows the reader's own `remoteAccess/follow`, and each button and menu entry reads its endpoint's answer on the global resource, a Session, or a Workspace through `useRemoteAccess`, `useAccess` or `useSessionAccess`, or `useWorkspaceAccess`, rendering again whenever the answer changes. The owner's DSH computes the snapshot from this share's `share-viewer` policy, so an ability the owner withdraws closes without reloading the page — and the server refuses those actions anyway; the page only avoids offering what would be refused. The served index carries the reader's opening snapshot, so no entry appears before the page knows it is allowed, and after the snapshot stream fails every answer is refused. The owner's own page opens with the owner baseline and behaves exactly like an ordinary DSH. Per-Session refusal reasons are not part of the snapshot: the page states the page-level reason (share mode, stop, expiry, lost access) and generic read-only copy. A page that cannot read the Session at all — offline owner, stopped or expired share, lapsed sign-in — states that condition at the top and gives the reader an action, instead of leaving an empty shell or a bare code.
### Two driving tiers
Every share carries a `ViewerSessionMode` with two values, and both drive the shared Session:
- **view** — the reader drives the shared Session's loop through the normal prompt path, inside a read-only sandbox in the granted workspace, so the Session's tools never write a file. The read and search surface (`read`, `read_image`, `glob`, `grep`, `present`), the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`). Every declared path is checked before the tool runs, and a tool whose call cannot be checked is refused.
- **write** — the reader drives the same loop in a bounded writable sandbox that also modifies files inside the granted workspace, under the Host's write ceiling.
The sandbox is installed on the Session, but it confines only the turns a share viewer starts: `agent/pre-step` records whether an admitted turn's entering input names a share viewer, and the guard and the replacements read that answer for each execution. A turn the owner starts keeps the Session's tools; an owner's steering input inside a viewer's turn stays confined with it. The marker lives in Host memory, is recomputed for each turn, and never enters the Session log. Stopping every share, or letting them all expire, disposes the layer and gives the Session its own tools back.
The earlier vocabulary had three tiers — `view` watched, `fork-readonly` branched, and `write` could write. `view` and `fork-readonly` differed only in whether the reader could branch and drive a child, which the share's own `allowsFork` switch already decides; a third mode duplicated that switch and let the two answers disagree, so the two driving tiers replaced it.
Forking is not a tier. The share carries its own `allowsFork` switch, independent of the tier: a `view` share may allow it and a `write` share may refuse it. A fork creates a child the viewer owns with its own read-only or writable ceiling and leaves the source untouched.
Attachment upload is not a tier or a switch. `fileUploads/upload` rides the read role, so every Session a share serves the reader accepts files; visibility alone decides it, and the owner has no separate attachment switch.
A mode this version does not recognize, and a persisted record whose mode the host cannot establish, are not driven: the share stays readable, and the page reports what it could not establish. The withdrawn `fork-readonly` name is not read back as `view`; a persisted owner record that names it is rejected when the owner state loads.
The sandbox is a property of the Session, but it applies per turn: the Host records, in memory, whether the admitted input of the running turn names a share viewer, and confines that turn while leaving an owner-started turn on the Session's native tools. The earlier shipped design confined every turn, including the owner's; it needed no turn read, but a share changed how the owner's own work ran. Persisting a turn's requester was rejected: a committed-message projection had to be folded, re-read for every execution, and kept in step with the share that admitted it, and it still had to fail closed on mixed or unestablished requesters. The in-memory marker keeps the guard and the replacements reading one answer per execution without adding a Session event; a restart recomputes it from the next admitted step, and a rejected or never-admitted step leaves no marker.
### Default visibility
The package ships as a runtime dependency of every dsh installation, but a default installation must show it nowhere in the plugin manager: neither under **Built-in plugin settings**, nor under **Experimental plugins**, nor on the **More** page. A person enables it deliberately — by inserting the bundle's rows in the active profile's `cordis.patch.yml`, or by selecting the bundle in `dsh.profile.bundles`; the [package README](../../../../packages/experimental/liveshare/README.md) owns both routes. The package must still ship with the release and stay installable, and users who enabled it before this rule keep seeing it.
The Host's bundle `enabled` flag states whether the profile selected the bundle, which is not the same as whether the bundle's plugins run: inserting the bundle's rows through `cordis.patch.yml` starts its plugins while leaving the bundle unselected, so a visibility rule built on `enabled` would hide a bundle the person had just enabled and would strand the documented enable path.
The package declares the hiding intent in its own bundle manifest as `dsh.bundle.listedByDefault: false`; the manager keeps one generic visibility rule rather than a client-side name set. A bundle whose manifest does not declare `listedByDefault: false` is listed; one that declares it is listed only when the profile enables it, the installation carries it selected, or one of the rows its bundle patch declares is running (`pkg.rows.some(row => row.enabled)`). The filter applies before both lists are derived, so the name is absent from the main Experimental plugins group, the More page, Built-in plugin settings, and the Installed plugins group until one of those holds. The Host's `listBundles` contract, the profile format, and the installed package set are unchanged. `BundleInfo.rows` already joins each declared row with the Loader entry currently carrying it, and the client's `PackageView` carries whether that entry runs, so row state is the effective answer for both enable routes: removing the rows, disabling them, or deselecting the bundle stops them and the card leaves both lists again.
A default-hidden bundle is not addressable by ID while its card is hidden: `openPkg` resolves only the listed inventory, so `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` renders no details until the same enablement conditions list it.
### How shared work stays inside its grant
The Owner, not the viewer page or the Liveshare server, authorizes each Session operation. A child receives its real scoped tool restrictions before it can accept a prompt, including after a cold resume: the host installs the limit from the base `agent/created` event. That listener runs after the agent entered the registry, but creation has not resolved, AgentLoop holds the agent's queued input, and a throw fails creation, so a refused limit still prevents any prompt. A missing host service or failed installation refuses the child; a configuration flag, a tool name, or a promise to install later is not enforcement.
A shared source Session can still serve its owner's own work. Every reader call is dispatched through the owner's own Connection and Gateway as that share-and-reader pair's own Peer, whose `share-viewer` principal (the share id and the server-asserted identity triple) is fixed by in-process owner code when the Peer is created; no wire field can change it. The Gateway decides the Peer's calls, and the Session notifications it forwards to the Peer, through remote access: this package registers the `share-viewer` policy, which binds a served reader to read roles on the shared Session, its own forks, and the Workspaces holding them, and to fork and write roles whose conditions apply the share mode; it binds nothing else, so every other endpoint and notification is refused, and it binds nothing once the share stops serving the reader. The owner invalidates the reader's access on every grant, revocation, mode change, and expiry, and disposes the reader's Peer when the grant ends or the share stops or expires. A reader's prompt enters the Session as a `user-rpc` message whose `source.principal` names that reader; owner messages carry no principal. While any share over that Session is active, the owner host mounts its own confined replacements for the read and search surface and the no-effect tools — plus bounded `edit` and `write` when some share may write — in the Session's own scope, and a viewer-started turn may run nothing else. An owner-started turn keeps the Session's native tools, so a share never narrows the owner's own work. The pre-step records which kind of turn is running, in memory only. Each operation re-reads the shares' current Relay grants and checks the resolved target against the granted root; the pre-step re-checks every entering message's viewer against the Relay's admission before a model step, so reader input queued before a revocation never drives the model. A reader's stop of the shared Session is dispatched through the Gateway like a prompt: the policy admits it while the share's tier is established and the Session is confined, and the owner's own Session controller stops whatever turn that Session is running, the owner's included. The general Peer, principal, and remote-access design is in [Peer principals and remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md).
A confined file tool resolves every path its own arguments declare, checks that the granted root contains each result, and hands the deployment's tool that canonical path; a pathless search uses the Session's working directory, which the replacement checks like a named path. A bounded edit or write performs the same containment check and then edits or writes through the filesystem backend with the Session's current sandbox policy and the normal observation policy.
### Keeping one branch across retries
One fork request binds its viewer, share, source, operation identity, and completed prefix. The Owner reserves that operation durably before creating a child, and records the child the owner's fork answer returned; a retry of a recorded operation replays that answer instead of forking again. A different prefix under the same identity is refused. The owner's `SessionForkRequest` mints the child id, so the Host sends no child identity: a host that dies after the child was created but before the operation was recorded cannot name that child on retry and creates a new one. Closing that window needs a Session Controller request that accepts a caller-supplied child identity. The inherited prefix stops before unrelated queued input; only immediately following request metadata is retained. Revoked children keep their logs but cannot resume with broader access, and removing a share does not expose the source Session's unrestricted tools.
### Who the owner is
A share can only be created by a verified owner. The dialog shows the identity the owner's DSH read back — name, expiry, when it will renew — and uses a label derived from the identity when there is no display name. The code that creates the share refuses an unverified owner as well, so the question is not settled by whether a button was clickable.
Signing in happens once, in a window the page opens while the owner clicks. The DSH creates a short-lived transaction tied to the Liveshare server address, to the Liveshare server instance, and to the page allowed to finish it; the page opens the sign-in page the DSH names, accepts one one-time code only from that exact origin, window, and transaction, and hands it back to its own DSH.
The Owner redeems the code with the verifier it retained, stores the credential with owner-only permissions, and sends it only to the Liveshare server address that issued it. New registration requires that verified credential; existing-share reconnect uses the share's management token, and explicit identity claim preserves the share's ACL, mode, expiry, and token. A share's named colleagues are the owner's stored selection plus the deployment's own allowlist; nothing is derived from a display identity.
### What the reader sees at the top
The served page reuses the sidebar's existing brand row instead of adding a bar on top of the app: the usual Harness mark, plus a LiveShare entry that names who shared the session and which DSH version they run, with the Liveshare server's own release labeled separately. Clicking it returns to the page listing the shares you can open.
### When the connection breaks
A reader's feed can fail: a stream disappears, the Liveshare server cancels a request, a read times out. The page retries only the failures that are defined as repeatable, and stops for good on authentication, permission, and missing-share errors, showing the reason instead of reconnecting forever.
### How the served page is exercised
The page a viewer opens is the owner's own Web page. The Liveshare server asks the owner for its rendered index document, places a short carrier script at its top, and passes the page's shell assets and plugin bundles through from the owner, so the page runs every owner plugin at the owner's version. The carrier only turns the page's calls and streams into reads over the Liveshare server's channel; the Liveshare plugin itself recognizes a viewer page and mounts the viewer surfaces instead of the Share action. The owner renders that index with the reader's own access snapshot in place of the owner baseline, and the page then follows the reader's `remoteAccess/follow` snapshot; no other read carries a reader's grants.
The viewer page is tested in-process: the test assembles the page from the carrier, the shell, and the client plugins including Liveshare's, stands in for the Liveshare server's read channel, and answers the page's reads from a scripted owner, so the assertions see the real shell, carrier, and presentation plugins.
### What the package exposes
The package ships one Host program and one browser face. Its public entries are the Host export (`lib/index.js`), `./client` (`lib/client.js`, a closure-factory bundle), `./viewer-declarations`, the share-management Remote's `./typert` and `./remote` faces, `./types`, the bundle patch `./cordis.patch.yml`, and the locale JSON files. The manifest declares the browser face as `dsh.client` with `platform: web` and the client services it needs; that face reads `__DSH_LIVESHARE_VIEWER__`, mounts the viewer session plugin and its brand, model, permission, session-row, and share-path surfaces on a served viewer page, and otherwise mounts the owner's generated Remote before the Session-header Share action.
Share management is one `liveshareShares` Typert Remote with twelve methods — `create`, `list`, `stop`, `updatePermissions`, `setCapabilities`, `capabilities`, `relay`, `setRelay`, `identity`, `audit`, `testRelay`, and `testPublish` — over the operations the owner host runs. The only same-origin HTTP routes left are the four owner sign-in and credential routes: `/api/liveshare.ownerLoginStart`, `/api/liveshare.ownerLoginRedeem`, `/api/liveshare.ownerLoginCancel`, and `/api/liveshare.ownerCredential`.
The outgoing gateway is a supervised child process whose source entry is `src/owner-gateway/cli.ts` and whose published entry is `lib/owner-gateway/cli.js`; the directory name keeps it distinct from the DSH API gateway.
## Alternatives considered
- **Build a separate read-only page instead of serving the normal client.** Rejected: reading needs browsing, grouping, and selecting, so a second page would have to rebuild all of it and would quietly lose the navigation the product keeps.
- **Decide each button's availability where the button is.** Rejected: every entry reads one access snapshot the owner computes, so a screen added later cannot quietly reintroduce an action the reader may not perform.
- **Let the reader's page decide its own access.** Rejected: only the owner and the service decide what a share grants; the page states what it was told.
- **Treat an older share with no mode as writable.** Rejected: that would hand a reader access the owner never configured, so a share without a mode is read-only.
- **Put the sign-in code in the page address, or read the credential from the popup's cookies.** Rejected: addresses leak through history, referrers, and logs, and a page should not hold credentials.
- **Show the service's own error text when the page cannot explain a refusal.** Rejected: that text may contain a file path, a credential, or another reader's session, so the page shows the stable reason it can vouch for.
- **Put execution restrictions only in editable configuration or a sandbox mode.** Rejected: a child can change configuration, and per-call execution choices can outrank a mode. The real operation needs a monotonic guard and a verified implementation.
- **Check a model-supplied path only before dispatch, or globally constrain every filesystem read.** Rejected: a pre-dispatch check is separated from the tool call by other listeners, while a global read rule would also constrain unrelated Owner work. The containment check runs inside the authorized Session's own read and write.
- **Reuse the deployment's default root or copy the entire source tail into a child.** Rejected: both can include files or pending input the viewer was never granted.
- **Use a deployment-wide secret or self-reported display fields as owner identity.** Rejected: neither identifies the logged-in person who approved this publication.
- **Keep three tiers.** Rejected: `view` and `fork-readonly` differ only in whether the reader can branch and drive a child, which is already the independent fork switch. A third mode duplicates that switch and lets the two answers disagree.
- **Keep a per-share attachment-upload switch.** Rejected: an attachment is part of a prompt on a Session the reader can already see. The separate switch duplicated visibility and could only subtract from it, while the upload role already rides the read role.
- **Attribute every turn to a requester and exempt the owner's own turns.** Rejected after it shipped, then narrowed: persisting a turn's requester needed a Host-only projection over message sources, a turn-boundary read per execution, and a re-check of the share that admitted each turn, and it still had to fail closed on mixed or unestablished requesters. The shipped design keeps only an in-memory marker for the running turn — `agent/pre-step` reads the entering principals and the guard reads that answer — so an owner-started turn keeps the Session's native tools without adding a Session event. The accepted cost is that an owner's steering input inside a viewer's turn stays confined; a viewer message that reaches a later step narrows that turn.
- **Let each tool name its own tier or sandbox.** Rejected: the tier belongs to the share. A per-call choice would let one execution outrank the grant that admitted it.
- **Test the bundle's `enabled` flag directly.** Rejected: it would keep the code smallest, but the patch route starts the bundle's plugins while leaving the bundle unselected, so the card would stay hidden exactly when its plugins started, contradicting the product rule.
- **Derive effective enablement in the Host.** Rejected: `listBundles` could report a new or redefined effective state, but that would change a cross-package contract for one client-only presentation rule and invite every bundle consumer to reinterpret `enabled`, which must keep meaning "selected in the profile".
- **Ship LiveShare outside the installation's optional bundles.** Rejected: that would make it visible nowhere by construction, but the package must still ship as an npm package with the release and be installable, and it would also remove the bundle from the Host's list entirely, taking the details entry with it.
- **Keep the colleague picker, or replace it with a directory-backed selector.** Deferred: naming a colleague needs an identity directory the Liveshare server does not provide, so the dialog cannot offer a choice it can verify. Stored `selected` and `approval` records keep working through their ACL, and the creation entry point stays retired until a directory contract exists; `liveshareShares.create` keeps its fields for those records and direct Remote callers, so this defers the API rather than deleting it.
## Verification
- A reader's page keeps the normal navigation, and no action the reader may not perform appears on the shared session or on any branch.
- A DSH without the Liveshare bundle, and the owner's own page, behave exactly as before.
- A share uses one of two tiers and that tier's sandbox; unknown modes, unreadable answers, and shares without a mode stay readable but are never driven.
- A share cannot be created without a verified owner identity.
- A share created from the dialog carries `open`; a stored `selected` or `approval` record keeps its recorded ACL and access summary, and `create` still stores a requested `selected` mode with its subjects.
- Sign-in accepts one one-time code from the transaction's origin, window, and state, and nothing else; a failed or abandoned attempt discards the transaction, and no credential, cookie, or code reaches a URL, storage, or a log.
- Real Controller, ToolRuntime, filesystem, and profile tests cover fork identity, cold enforcement, bounded execution, revocation, and normal cancellation. The built viewer must use its real POST carrier and Owner authorization handler; deterministic display fixtures do not replace execution or persistence tests.
- Generated API documentation and both SDK projections remain aligned with the `user-rpc` source principal, which only the `agent/pre-step` admission check consumes.
- `ViewerSessionMode` has two members, and a persisted `fork-readonly` record is rejected when the owner state loads.
- A `view` share drives the source Session, refuses file writes and out-of-grant reads, and can fork when `allowsFork` is set.
- `fileUploads/upload` is allowed on every Session the reader can see, for every tier and fork combination.
- Source tool decisions read only the Session's mounted layer: the guard permits this installation's confined replacements for the read and search surface and the no-effect tools, plus bounded `edit` and `write` on a writable Session, and refuses every other name, with no turn, requester, or origin consulted.
- Unit and real-runtime tests cover the tier policy, confined reads and writes, fork ceilings, the layer's installation and release, and the pre-step admission check over the durable message source.
- The plugin-manager component tests hold the bundle out of both lists while its declared rows are off, list it once a declared row runs, and still open it by bundle ID while it is hidden; the recorded goldens and the refresh card count move with that rule.
## Consequences
The reader's page can only be as accurate as what the service tells it. Capability answers, the owner's identity status, the session mode, the owner's DSH version, and the sign-in endpoints all come from the owner's DSH and the Liveshare server; until those are deployed together with this layer, the page stays at its most conservative setting — read-only, with a sign-in that cannot complete — rather than guessing.
The page is not a security boundary. The owner's DSH and the Liveshare server refuse a reader's writes no matter what the page does; the access snapshot exists so the page does not offer actions that would only be refused.
Existing ACLs are retained. A record without a mode is readable but not driven, and contradictory mode fields are refused. Unattributed shares are shown as unverified history.
Owner reconnect state is atomically replaced and queued within one process, but multiple processes sharing one file can still lose updates or compete for a gateway. Cross-process execution ownership is unresolved; atomic replacement alone is not a transaction or a route claim. Containment is checked by path: a local process that replaces a directory inside the grant with a symlink between the check and the open can redirect a viewer's read, or a write elsewhere within the sandbox's writable roots. Operations anchored to a directory handle would close this window and are left to a separate change. Final viewer-artifact and SDK snapshot evidence remains required before release.
A `view` share is not watch-only: it runs the reader's prompts and tool calls inside the read-only sandbox. A deployment that wants a passive reader has no separate mode and must rely on the reader not sending prompts rather than on a tier name. Fork reach is the only independently switchable action; attachment upload cannot be disabled separately.
The sandbox reaches the owner's own turns too. While any share over a Session is active the owner can only read inside the granted workspace, and can write only when some share allows writing; a share that is merely published — even one nobody is using — therefore changes the owner's own tool surface until it is stopped or expires. Cancellation has no attribution either: a reader's stop ends whatever turn the shared Session is running.
The default cards list, the More page, and their recorded goldens lose one card, and the manager's refresh card count drops by one. A person who only knows the plugin page cannot discover LiveShare or switch it on from that page; the package README owns the two enable routes instead. A bundle enabled through the patch route shows its card with the switch off while its rows run, because the switch reflects the profile's bundle selection and writes that selection; the documentation states the two routes and this difference. The rule covers one explicitly named bundle rather than a manifest flag, so adding another hidden-until-running bundle, or retiring this one, is a one-line set edit with a test and docs update.
Shipped creation produces organization-link shares only: the Share dialog sends neither `accessMode` nor `allowedSubjectIds`, so every new share is `open`, and the deployment's `LIVESHARE_ALLOWED_SUBJECTS` is the only subject-level narrowing a new share can carry. Stored `selected` and `approval` records keep working and keep their access summaries, and the create fields stay on the Remote for those records and direct callers while no UI exposes them.
@@ -0,0 +1,156 @@
# Agent Note: Liveshare —— 把 DSH 会话分享给同事看
Status: implemented
[English](2026-09-13-liveshare-protocol-and-owner-surfaces.md) | 中文
## Problem
DSH 会话跑在你自己的电脑上。同事有时需要在你工作的同时看某个会话——跟着一起排查问题,或者看看一个长任务进展到哪了。现在没有这个能力。把会话日志发过去并不等价:对方要有你的插件、配置和沙箱才能渲染出来,而且会话只有在你机器上运行时才有意义。
所以一次分享要同时解决三件事:一个**同事用浏览器打开就能看的页面**,看到你机器上正在跑的会话,但不需要连到你的机器;一个**对页面能做什么的限制**,从"能读、不能写文件"开始——页面多给一个你没同意的操作就是安全漏洞,不是便利;以及一个**"这是谁分享的"的答案**,因为说不清归属的页面,打开它的人无从信任。
这条 Note 覆盖 owner 自己 DSH 里运行的那一半——分享对话框、背后的 owner 端服务、本包的浏览器端,以及读者打开的页面——以及双方共用的协议包。中间的 Liveshare 服务端是独立的包(`@deepseek-ai/dsh-experimental-liveshare-relay`),单独审查与部署。
## Decision
### 一次分享是什么
owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服务端(双方都能访问的服务)后,DSH 返回一个链接。打开链接看到的是 owner 自己的 DSH 用自己插件渲染出的会话,读者不需要 owner 的配置。读者列表包含源会话,以及该读者通过分享创建的子会话。工作区名称、成员和顺序裁剪为这些会话;无关会话和工作区保持不可见。
从 DSH 分享弹窗新建的分享是**组织内持链接**分享:它携带 `accessMode: 'open'`,组织内拿到链接的人都能看。部署级 `LIVESHARE_ALLOWED_SUBJECTS` 会并入每份分享的 ACL:对 `open` 分享,它把准入收窄到列出的主体;对已存储的 `selected` 记录,它把列出的主体加在记录自己的选择旁边。
**指定同事(`selected`)与更早的 `approval` 拼写是旧版持久记录。** Host 仍按记录里的 ACL 读取它们并展示访问摘要,Liveshare 服务端仍按该 ACL 接纳它们的查看者,但随版本发布的分享弹窗不提供新建入口。`liveshareShares.create` 的 `accessMode` 与 `allowedSubjectIds` 字段保留给这些记录以及直接调用该 Remote 的调用方;随版本发布的弹窗两个都不发送。
### 读者能做什么
读者页面就是正常的 DSH 客户端,不是精简版:侧栏、会话列表、展开折叠、选择都在,因为浏览本来就是"看"的一部分。缺的只是读者无权执行的动作——创建无关会话、重命名、归档、删除、管理工作区。分支遵循分享自己的 fork 开关;发送消息在两档里都可用,并在该档的沙箱内运行;附件上传跟随读者可见的会话。管理权限与档位和 fork 开关都保持独立。
这些动作不是用 CSS 藏起来的。页面能做什么由一份访问快照决定:remote-access 客户端跟随读者自己的 `remoteAccess/follow`,每个按钮和菜单项经 `useRemoteAccess`、`useAccess` 或 `useSessionAccess`、`useWorkspaceAccess` 读取其端点在全局资源、某个会话或某个工作区上的答案,答案变化时重新渲染。owner 的 DSH 依据该分享的 `share-viewer` 策略计算快照,因此 owner 收回某项能力时,对应入口不用重新加载页面就会关闭——而且服务端本来就会拒绝这些操作,页面只是不去提供注定被拒绝的按钮。读者收到的 index 已带上读者的首份快照,因此页面在确认允许之前不会显示任何入口;快照流失败后,所有答案都为拒绝。owner 自己的页面以 owner 基线打开,行为与普通 DSH 完全一致。快照不包含逐会话的拒绝原因:页面陈述页面级原因(分享模式、停止、过期、失去访问权)和通用的只读文案。拿不到分享正文时,页面顶部直接陈述该状态,并且每一档都给出读者自己能做的动作(重试、重新登录、回到分享列表,或明确指引联系分享者),不会停在空白外壳或错误码上。
### 两档驱动
每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话:
- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。
- **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。
沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。
较早的词汇有三档——`view` 只观看、`fork-readonly` 分支、`write` 可写。`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经由分享自己的 `allowsFork` 开关决定;第三档重复了该开关,还会让两处答案互相矛盾,因此由两档驱动取代。
fork 不是一档。分享自带独立的 `allowsFork` 开关,与档位无关:`view` 分享可以允许 fork,`write` 分享也可以拒绝。fork 创建的子会话归读者所有,带有自己的只读或可写上限,源会话保持不变。
附件上传既不是一档,也不是一个开关。`fileUploads/upload` 随读角色授予,因此分享向读者提供的每条会话都接受文件;只由可见性决定,所有者没有单独的附件开关。
本版本不认识的模式、以及宿主无法确定模式的持久记录,都不会被驱动:分享仍可读取,页面会说明它无法确定什么。已退场的 `fork-readonly` 名称不会读回为 `view`;持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。
沙箱是会话的属性,但按轮次生效:Host 在内存中记录运行中轮次的已准入输入是否带有 share-viewer principal,只约束该轮次,把所有者发起的轮次留在会话原生工具上。此前上线的实现限制每个轮次(包括所有者的),虽不需要读取轮次,却让分享改变了所有者自己的工作方式。持久化轮次请求者被否决:那需要从已提交消息来源折叠的投影、每次执行读取 turn boundary、并与接纳该轮次的分享保持同步,而且在混合或无法确立请求者时仍必须默认拒绝。内存标记让守卫与替代定义对每次执行读取同一个结论,且不新增会话事件;重启后从下一个准入步骤重新计算,被拒绝或从未准入的步骤不留下标记。
### 默认可见性
本包作为运行时依赖随每个 dsh 安装一同交付,但默认安装必须在插件管理器里完全不显示它:**内置插件配置**下不显示,**实验性插件**下不显示,**更多**页也不显示。用户主动启用它——在当前 profile 的 `cordis.patch.yml` 中插入组合包的行,或在 `dsh.profile.bundles` 中选中该组合包;两条启用路径由[包 README](../../../../packages/experimental/liveshare/README.zh.md)负责。该包仍必须随版本发布、保持可安装,且在此规则之前已启用的用户继续能看到它。
Host 的组合包 `enabled` 标志表示 profile 是否选中了这个组合包,这与它的插件是否在运行并不等价:通过 `cordis.patch.yml` 插入组合包的行会让插件运行,而组合包本身仍未选中;因此以 `enabled` 为准的可见性规则会把用户刚刚启用的组合包隐藏起来,使写入文档的启用路径失效。
因此管理器维护一个 `HIDDEN_UNTIL_RUNNING` 组合包名集合——当前只有 `@deepseek-ai/dsh-experimental-liveshare`——集合中的组合包只有在它 patch 声明的行至少有一条在运行时才会被列出(`pkg.rows.some(row => row.enabled)`)。过滤发生在两个列表派生之前,因此在满足条件前,它不会出现在主"实验性插件"组、**更多**页、**内置插件配置**和"已安装插件"组中。该集合是 `PluginManagerPage.tsx` 中的客户端数据;Host 的 `listBundles` 契约、profile 格式和已安装包集合都保持不变。`BundleInfo.rows` 已把每条声明的行与当前承载它的 Loader 条目关联起来,客户端的 `PackageView` 携带该条目是否在运行,因此行状态就是两条启用路径的有效答案:删除这些行、把它们置为 disabled,或取消选中组合包,都会让它们停止运行,卡片随之从两个列表消失。
卡片隐藏期间组合包 ID 仍可按 ID 打开:`openPkg` 会从未过滤的 Host 组合包列表中解析隐藏的组合包,因此 `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` 仍能渲染其详情。其它按 ID 链接组合包的界面因此继续可用,组合包页也像其它未选中组合包的页面一样列出各行及其关闭状态。
### 分享工作如何限制在授权范围内
Owner 而不是查看页或 Liveshare 服务端授权每次会话操作。子会话必须先安装真实作用域工具限制,才能接受消息,冷恢复后也一样:宿主机从基础 `agent/created` 事件安装该限制。该监听器运行时智能体已登记进注册表,但创建尚未完成,AgentLoop 会扣住其排队输入,且监听器抛错会使创建失败,因此被拒绝的限制仍能阻止任何消息。宿主机服务缺失或安装失败都会拒绝子会话;配置开关、工具名或稍后安装的承诺都不是执行限制。
共享源会话仍可服务所有者自己的工作。每次读者调用都以「该分享与该读者」专属的 Peer 经所有者自己的 Connection 与 Gateway 分派,该 Peer 的 `share-viewer` principal(分享 ID 与服务端断言的身份三元组)由所有者进程内代码在创建 Peer 时确定,任何线路字段都无法改变它。Gateway 经 remote access 判定该 Peer 的调用以及转发给它的会话通知:本包注册 `share-viewer` 策略,把受服务的读者绑定到共享会话、其自己的 fork 及包含它们的工作区上的读取角色,以及条件按分享模式判定的 fork 与写入角色;除此之外不做任何绑定,因此其它端点和通知一律被拒绝,分享不再服务该读者后也不再有任何绑定。所有者在每次授权、撤权、模式变化与过期时使读者的访问失效,并在授权结束、分享停止或过期时释放读者的 Peer。读者的提示词以 `user-rpc` 消息进入会话,其 `source.principal` 指明该读者;所有者的消息不带 principal。只要该会话还有分享活跃,所有者 Host 就在会话自身作用域内挂载读取与检索面与无副作用工具的受限替代,以及(某个分享允许写入时)有界 `edit` 与 `write`,查看者发起的轮次只能运行这些。所有者自己发起的轮次保留会话原生工具,因此分享不会收窄所有者自己的工作。pre-step 只在内存中记录当前轮次属于哪一类。每次操作都会重新读取分享当前的 Relay 授权,并按授予根目录检查解析后的目标;pre-step 在模型步骤前按 Relay 准入重新检查每条进入消息的查看者,因此撤权前排队的读者输入永远不会驱动模型。读者对共享会话的停止与提示词一样经 Gateway 分派:策略在分享档位确立且会话受沙箱限制时放行,随后由所有者自己的会话控制器停止该会话当前运行的轮次,包括所有者发起的轮次。通用的 Peer、principal 与 remote access 设计见 [Peer principal 与 remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含解析结果,再把规范路径交给部署的工具;无路径的搜索使用会话工作目录,由替代定义按命名路径一样检查。受限编辑或写入执行同样的包含检查,然后按会话当前沙箱策略和正常观察策略经文件系统后端编辑或写入。
### 重试时保留同一个分支
一次 fork 请求绑定查看者、分享、源会话、操作身份和已完成前缀。Owner 在创建子会话前持久化预留该操作,并记录 owner 的 fork 应答返回的子会话;重试已记录的操作会重放该应答,而不是再次 fork。同一身份指定不同前缀会被拒绝。owner 的 `SessionForkRequest` 自己生成子会话 id,因此 host 不发送子会话身份:若 host 在子会话已创建、但操作尚未记录时退出,重试无法指名该子会话,会再创建一个。要关闭这一窗口,需要 Session Controller 的请求接受调用方提供的子会话身份。继承前缀停在无关排队输入之前;只保留紧随其后的请求元数据。被撤销的子会话保留日志,但不能以更宽权限恢复;移除分享不会暴露源会话不受限制的工具。
### owner 是谁
只有已验证的 owner 才能创建分享。对话框显示 owner 的 DSH 回读到的身份——名字、有效期、何时续期——没有显示名时用身份派生出来的标签。创建分享的代码本身也会拒绝未验证的 owner,不是只看按钮能不能点。
登录只做一次,且发生在 owner 点击时页面预留的窗口里。DSH 生成一个短寿命事务,绑定 Liveshare 服务端地址、Liveshare 服务端实例,以及允许完成它的页面;页面打开 DSH 指定的登录页,只接受来自该 origin、该窗口、该事务的一次性 code,并把它交回自己的 DSH。
Owner 使用自己保留的校验值兑换代码,以仅所有者可读写的权限保存凭据,并只向签发它的 Liveshare 服务端地址发送。首次登记需要已验证凭据;已有分享重连使用分享管理 token,显式身份认领保留该分享的 ACL、模式、过期时间和 token。分享指定的同事是 owner 的存储选择加上部署自身的允许列表;不会从显示身份派生任何内容。
### 读者页面顶部显示什么
被服务的页面复用侧栏原有的品牌行,而不是在应用上再压一条横条:原本的 Harness 标记,加上一个 LiveShare 入口,写明是谁分享的、对方跑的是哪个 DSH 版本(Liveshare 服务端自己的 release 另行标注)。点击它回到"我能看的分享"列表页。
### 连接断开时
读者的数据流可能失败:流消失、Liveshare 服务端取消请求、读取超时。页面只重试定义上可重复的失败;遇到认证、权限与分享不存在的错误就直接停下并显示原因,不会无休止地重连。
### 服务出去的页面怎么测
读者打开的页面就是 owner 自己的 Web 页面。Liveshare 服务端向 owner 要渲染好的 index 文档,在最前面插入一段很短的 carrier 脚本,页面的 shell 资源和插件 bundle 也从 owner 透传过来,所以页面跑的是 owner 版本的全部插件。carrier 只把页面的调用和流转成 Liveshare 服务端通道上的读请求;由 Liveshare 插件自己识别 viewer 页,挂上 viewer 界面,而不是分享按钮。owner 渲染这份 index 时用读者自己的访问快照取代 owner 基线,页面随后跟随读者的 `remoteAccess/follow` 快照;其他读请求都不夹带读者的授权。
读者页面在进程内测试:测试用 carrier、shell 和包括 Liveshare 在内的 client 插件拼出页面,顶替 Liveshare 服务端的读通道,并用脚本化的 owner 回答页面的读请求,因此断言看到的是真实 shell、carrier 与呈现插件。
### 本包对外提供什么
本包交付一个 Host 程序和一个浏览器端。公开入口包括 Host 导出(`lib/index.js`)、`./client`(`lib/client.js`,closure-factory bundle)、`./viewer-declarations`、分享管理 Remote 的 `./typert` 与 `./remote` 两个面、`./types`、组合包 patch `./cordis.patch.yml`,以及 locale JSON 文件。manifest 以 `dsh.client` 声明浏览器端,`platform` 为 `web`,并列出它需要的客户端服务;该入口读取 `__DSH_LIVESHARE_VIEWER__`:在被服务的查看页上挂载查看页 session 插件及其品牌、模型、权限、会话行与分享路径界面,否则先挂载 owner 生成的 Remote,再挂载 Session 头部操作。
分享管理是一个 `liveshareShares` Typert Remote,共十二个方法——`create`、`list`、`stop`、`updatePermissions`、`setCapabilities`、`capabilities`、`relay`、`setRelay`、`identity`、`audit`、`testRelay` 与 `testPublish`——背后是 owner 宿主执行的操作。仅存的同源 HTTP 路由是四条 owner 登录与凭据路由:`/api/liveshare.ownerLoginStart`、`/api/liveshare.ownerLoginRedeem`、`/api/liveshare.ownerLoginCancel` 与 `/api/liveshare.ownerCredential`。
出站网关是受监督的子进程,源码入口为 `src/owner-gateway/cli.ts`,发布入口为 `lib/owner-gateway/cli.js`;目录名让它与 DSH API gateway 明确区分。
## Alternatives considered
- **单独做一个只读页面,不服务正常客户端。** 否决:阅读需要浏览、分组与选择,另做一个页面就得把这些全部重建,还会悄悄丢掉产品要保留的导航。
- **每个按钮各自判断能不能点。** 否决:每个入口都读取 owner 计算的同一份访问快照,后加的界面才不会悄悄重新引入读者无权执行的动作。
- **让读者页面自己决定权限。** 否决:分享授予什么,只由 owner 与服务端决定;页面只陈述被告知的结果。
- **把没有模式字段的旧分享当作可写。** 否决:那会把 owner 从未配置的权限交给读者,因此没有模式的分享一律只读。
- **把登录 code 放进页面地址,或从弹窗的 cookie 读凭据。** 否决:地址会经浏览历史、referrer 和日志泄漏,页面也不应该持有凭据。
- **页面解释不了某个拒绝时,直接展示服务端的错误原文。** 否决:那段文本可能包含文件路径、凭据或别的读者的会话,所以页面只显示它能担保的稳定原因。
- **只在可编辑配置或沙箱模式中写执行限制。** 否决:子会话能够改变配置,而逐调用执行选择可能优先于模式。真实操作必须经过单调拒绝的守卫和已验证实现。
- **只在分派前检查模型路径,或全局限制所有文件系统读取。** 否决:分派前检查与工具调用之间还隔着其他监听器,而全局读取规则还会限制无关 Owner 工作。包含检查在已授权会话自身的读写中执行。
- **复用部署默认根目录,或把源会话整个尾部复制到子会话。** 否决:两者都可能包含查看者未获授的文件或待处理输入。
- **以部署共享密钥或自报显示字段作为所有者身份。** 否决:两者都不能识别批准本次发布的已登录人员。
- **保留三档。** 否决:`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经是独立的 fork 开关。第三档重复了该开关,还会让两处答案互相矛盾。
- **保留分享级的附件上传开关。** 否决:附件是读者已经能看到的会话上的一条 prompt 的一部分。单独的开关重复了可见性,且只能从可见性里做减法,而上传角色本来就随读角色授予。
- **按轮次归属请求者并让所有者轮次豁免。** 上线后否决,之后缩小范围:持久化轮次请求者需要仅 Host 可见的消息来源投影、每次执行读取 turn boundary、重新检查接纳该轮次的分享,而且在混合或无法确立请求者时仍必须默认拒绝。现在的实现只为运行中轮次保留内存标记——`agent/pre-step` 读取进入消息的 principal,守卫读取该结论——因此所有者发起的轮次保留会话原生工具,且不新增会话事件。接受的代价是:所有者在查看者轮次中追加的输入仍留在受限轮次内;查看者消息若进入后续步骤,会立即收窄该轮次。
- **让每个工具自行声明档位或沙箱。** 否决:档位属于分享。逐调用选择会让某次执行凌驾于批准它的授权之上。
- **严格的 manifest 选中判断。** 否决:直接判断 `pkg.enabled` 代码最少,但默认安装启用 LiveShare 的唯一文档路径是编辑 `cordis.patch.yml`,那不会让组合包被选中。卡片会在插件恰好开始运行时仍然隐藏,与产品规则矛盾。
- **在 Host 派生有效启用状态。** 否决:`listBundles` 可以以新字段或改写字段报告有效状态,但这会为一条纯客户端的展示规则改动跨包契约,并诱使每个组合包消费方重新解释 `enabled`——它必须继续表示"profile 已选中"。
- **把 LiveShare 移出安装随附的可选组合包。** 否决:将它从可选集合移除即可从构造上让它处处不可见,但该包仍需随版本作为 npm 包发布并可安装,而且这会把组合包从 Host 列表中完全移除,详情入口也会随之消失。
- **保留选人器,或换成由目录支撑的选择器。** 推迟:点名同事需要身份目录,而 Liveshare 服务端目前不提供;弹窗无法给出它能验证的选择。已存储的 `selected` 与 `approval` 记录仍按 ACL 工作,在有目录契约之前新建入口保持退役;`liveshareShares.create` 的字段保留给这些记录与直接调用方,所以这是推迟 API,而不是删除它。
## Verification
- 读者页面保留正常导航,且在共享会话与任何分支上都不会出现读者无权执行的动作。
- 没有 Liveshare bundle 的 DSH 以及 owner 自己的页面,行为与以前完全一致。
- 分享使用两档之一及该档的沙箱;未知模式、读不懂的应答、没有模式的分享仍可读取,但绝不会被驱动。
- 没有已验证的 owner 身份,就无法创建分享。
- 弹窗创建的分享携带 `open`;已存储的 `selected` 或 `approval` 记录保留记录中的 ACL 与访问摘要,`create` 仍会存储调用方请求的 `selected` 模式及其主体。
- 登录只接受来自该事务 origin、窗口与 state 的一次性 code,别的都不接受;失败或放弃都会丢弃事务,凭据、cookie 与 code 都不进入 URL、存储或日志。
- 真实 Controller、ToolRuntime、文件系统和 profile 测试覆盖 fork 身份、冷恢复限制、有界执行、撤权和正常取消。已构建查看页必须使用真实 POST 载体和 Owner 授权处理器;确定性显示 fixture(测试前置数据)不替代执行或持久化测试。
- 生成的 API 文档和两种 SDK 投影与 `user-rpc` 来源 principal 保持一致;该字段只由 `agent/pre-step` 准入校验消费。
- `ViewerSessionMode` 只有两个成员;持久化的 `fork-readonly` 记录在 owner 状态加载时被拒绝。
- `view` 分享驱动源会话,拒绝文件写入与越界读取,并在设置 `allowsFork` 时可以 fork。
- 对读者可见的每条会话,无论档位与 fork 开关如何组合,`fileUploads/upload` 都被允许。
- 源会话的工具决策只读取会话上挂载的执行层:守卫放行本次安装的读取与检索面与无副作用工具的受限替代,可写会话下另有有界 `edit` 与 `write`,拒绝其它所有工具名,不读取轮次、请求者或来源。
- 单元测试与真实运行时测试覆盖档位策略、受限读写、fork 上限、执行层的安装与释放,以及基于持久消息来源的 pre-step 准入校验。
- 插件管理器的组件测试在声明行全部关闭时把该组合包排除在两个列表之外,在声明行有一条运行后列出它,并在它隐藏时仍能按组合包 ID 打开;录制的 golden 与刷新卡片数随该规则变化。
## Consequences
读者页面只能准确到服务端告诉它的程度。能力回答、owner 身份状态、会话模式、owner 的 DSH 版本、登录端点,都由 owner 的 DSH 与 Liveshare 服务端提供;在与本层一起部署之前,页面保持最保守的状态——只读,且登录无法完成——而不是去猜。
页面不是安全边界。无论页面做什么,owner 的 DSH 与 Liveshare 服务端都会拒绝读者的写入;这份访问快照的作用只是让页面不去提供注定被拒绝的按钮。
已有 ACL 保持不变。无模式记录仍可读取,但不会被驱动;互相矛盾的模式字段被拒绝。没有归属的分享显示为“未验证历史”。
Owner 重连状态以原子替换写入,并在单进程内排队,但多个进程共享一个文件仍可能丢失更新或争用网关。跨进程执行归属尚未解决;原子替换本身不是事务或路由认领。包含关系按路径检查:若本地进程在检查与打开之间把获授范围内的目录换成符号链接,可以让查看者的读取改指别处,或让写入落到沙箱可写根内的其他位置。基于目录句柄锚定的操作可以消除这一窗口,留待单独变更引入。发布前仍需完成查看页产物和 SDK 快照验证。
`view` 分享不是"只看":它会在只读沙箱里运行读者的 prompt 与工具调用。想要被动观看的部署没有单独模式,只能依赖读者不发送 prompt,而不能依赖档位名称。fork 是唯一可独立关闭的动作;附件上传无法单独关闭。
沙箱同样作用于所有者自己的轮次。只要某会话还有分享活跃,所有者也只能在授予工作区内读取,且只有在某个分享允许写入时才能写入;因此一条仅仅发布、并没有人在用的分享也会改变所有者自己的工具面,直到它停止或过期。取消同样没有归属:读者的一次停止会结束共享会话正在运行的任何轮次。
默认卡片列表、**更多**页及其录制 golden 各少一张卡片,管理器的刷新卡片数也减一。只认识插件页的人无法从该页发现 LiveShare 或直接开启它;启用路径由包 README 负责。通过 patch 路径启用的组合包,其卡片显示为关闭状态而行在运行,因为开关反映并写入 profile 的组合包选中状态;文档会说明两条路径及其差异。该规则覆盖一个显式命名的组合包而不是 manifest 标志,因此新增另一个"运行前隐藏"的组合包或退役这一个,都只是一行集合改动,并配一条测试和文档更新。
随版本发布的创建流程只产生组织链接分享:分享弹窗不发送 `accessMode` 与 `allowedSubjectIds`,因此新分享一律为 `open`,部署级 `LIVESHARE_ALLOWED_SUBJECTS` 是新分享唯一能获得的主体级收窄。已存储的 `selected` 与 `approval` 记录继续工作并保留访问摘要;`liveshareShares.create` 的字段仍留在 Remote 上供这些记录与直接调用方使用,而界面不暴露它们。
@@ -9,14 +9,14 @@
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: b18b287b701aef93
zh: 462c0f561f1a0c18
en: 6a0dc836205863ba
zh: 3d4feb0385953dda
/agent-note-change-request-panel-and-worktree-sessions/deferred:
en: 32ef8af85f4d2f66
zh: b82b67f94346399e
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
en: 91b3338f958ad0e9
zh: b64f30d333280084
en: e471e16b6b6450ca
zh: 5fbc9242edc8d112
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
en: 81790c448f0a7d3b
zh: ec63ccb2ad315ff8
@@ -12,7 +12,7 @@ English | [中文](2026-10-09-forge-panel.zh.md)
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry.
The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun.
The Remote inspects the checkout through the Session's subprocess provider under its [resolved permissions and query restrictions](../bug-fix/2026-10-28-forge-git-session-permissions.md), so it reads what the Session's commands see. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun.
Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default.
@@ -24,7 +24,9 @@ The worktree a Session works in can be removed outside it. Each inspection check
The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`.
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from `git diff` using the Session's permissions and execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Review drafts and inline records belong to a browser tab occurrence, not to the Session log or a GitHub pending review. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records.
## Deferred
@@ -79,6 +81,12 @@ The changes view offers no scope for one commit or for the last turn yet; both w
**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests.
**Sending each line comment immediately to the Agent.** It starts a turn before the user can collect related comments or add a shared instruction; composer references preserve that choice.
**Keeping review drafts after their tab closes.** Reopening the same PR would restore abandoned review state. A tab occurrence gives closing a clear discard meaning while preserving Session switching.
**Automatically retrying a review write.** A lost response does not mean that GitHub rejected the request. Retaining the outgoing request and requiring reconciliation prevents a transport failure from silently duplicating comments.
## Consequences
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to twelve short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
@@ -12,7 +12,7 @@ Status: implemented
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
Remote 通过 Session 的子进程提供方,按其[解析出的权限与查询限制](../bug-fix/2026-10-28-forge-git-session-permissions.zh.md)检查检出,因此它读到的与 Session 命令看到的相同。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。
@@ -24,7 +24,9 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自使用 Session 权限与执行环境的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。评审草稿与行内记录归浏览器页签实例所有,不进入 Session 日志,也不创建 GitHub 待提交评审。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。
## Deferred
@@ -79,6 +81,12 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。
**把每条行内评论立即发送给 Agent。** 用户还没来得及收集相关评论或补充共同指令,就会开始一轮;输入框引用保留用户的选择。
**关闭页签后仍保留评审草稿。** 重新打开同一个 PR 会恢复已放弃的评审状态。页签实例让关闭明确表示丢弃,同时保留切换 Session 的状态。
**自动重试评审写入。** 丢失响应不代表 GitHub 拒绝了请求。保留发出的请求并要求核对,可以避免传输失败悄悄产生重复评论。
## Consequences
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十二个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
@@ -18,11 +18,11 @@
en: 1310a89c351e5bfe
zh: e6386ae10b893c2d
/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints:
en: 11937dfa4a349a19
zh: b91a136890fe92e8
en: add81088ce51033f
zh: c7065dc3052648fa
/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence:
en: f934c95748dcbfe0
zh: df6a68286c21510f
en: adb815c213373d8e
zh: 44d4c6752a4df51f
/agent-note-performance-baselines-for-large-session-corpora/alternatives-considered:
en: 4eaa4666262a1aee
zh: 7e2eb44b94bad05d
@@ -47,6 +47,8 @@ The shared corpus and projection-list controller fixture awaits controller activ
The file must finish within five minutes on standard hosted CI, including seeding, and its last case asserts that limit. Search over 1,000 Sessions and one fork of the longest Session take about 170 s of it, so each has one sample. The list corpus is the largest that fits the remainder. Budgets enforce medians of list boot, first, and repeat, and of the fork median over strata and rank 990; search and the longest fork enforce their single samples. Search and fork use separate 1,000-Session roots, so fork children never reach the search index. Rank 988 is the highest top-level rank at the measured p99 (7,229 events); forking a subagent child adds a lineage lookup, and rank 990 already falls in the 99.3% anchor. Peak RSS of the list, search, and fork processes has a budget with the shared 1.25 headroom and no time scale. Each child's deadline equals the file limit, and each test's deadline outlasts its sequential children, so a failed case reaps every child before the scratch root is removed.
Fork workers use a 512 MiB V8 old-space limit so every runner measures the same heap constraint. The corpus, timed endpoints, and whole-process peak RSS budget stay unchanged; native buffers still count toward RSS. The longest fork retains about 193 MiB after collection, so the limit leaves room for live objects while forcing collection before a larger host-selected heap delays it. This measures a bounded-heap workload, not the default-heap peak of every deployment.
## Calibration evidence
Budgets use standard two-CPU hosted CI expectations (Linux x64, Node 24.21, PR #5403), rounded above the higher recorded median across the AMD EPYC 7763 and EPYC 9V74 runners and multiplied by the shared 1.25 headroom. Samples varied by up to 15% within one run; the runner models differed by up to 25% per endpoint. The hosted-to-reference ratio is 2.4 to 3.4, above the shared reference scale of 2, so reference-machine scaling would reject ordinary hosted runs.
@@ -67,6 +69,8 @@ With seeding in one process, the complete file took 294.5 s on the EPYC 7763 run
On Apple M5 Pro with Node 26.5, isolated runs measured list 1,000 at 311–358 ms first, list 5,000 at 1,364–1,603 ms first, search at 57 s first, and fork of the longest Session at 6.0–6.4 s. A 10,000-Session list took 3.2–5.4 s for the first call with 1.9 GB peak RSS.
Node 24.22 hosted runs of the unchanged longest-fork workload recorded 696 MiB peak RSS on EPYC 9V74 and 1,077.7 MiB on EPYC 7763, against the same 938 MiB budget. On Apple M5 Pro with Node 24.19, two default-heap runs retained 192.8–193.4 MiB and peaked at 770.7–812.6 MiB in 2.416–2.447 s; two 512 MiB old-space runs retained the same range and peaked at 764.2–764.7 MiB in 2.487–2.528 s. The fixed limit controls the heap policy without raising the RSS or timing budgets.
## Alternatives considered
- **Sanitized copies of real Sessions.** Rejected: benchmarks never use recorded user material.
@@ -47,6 +47,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新
此文件(包括播种)必须在标准托管 CI 上五分钟内完成,其最后一个用例断言该上限。1,000 个 Session 的搜索和最长 Session 的一次 fork 约占其中 170 s,因此两者各取一个样本。列表语料是剩余时间内能容纳的最大规模。预算约束列表启动、首次和重复的中位数,以及 fork 分层中位数和排名 990 的中位数;搜索和最长 fork 约束各自的单个样本。搜索和 fork 使用各自独立的 1,000 个 Session 根目录,因此 fork 子会话不会进入搜索索引。排名 988 是实测 p99(7,229 个事件)上最高的顶层排名;fork subagent 子会话会增加一次谱系查找,而排名 990 已落入 99.3% 锚点。列表、搜索和 fork 进程的峰值 RSS 设有预算,使用共享的 1.25 余量,不做时间缩放。每个子进程的截止时间等于文件上限,每个测试的截止时间长于其顺序子进程之和,因此失败的用例会在删除临时根目录之前回收所有子进程。
Fork worker 使用 512 MiB 的 V8 old-space 上限,让各运行器在相同的堆约束下测量。语料、计时终点和整个进程的峰值 RSS 预算保持不变,原生缓冲区仍计入 RSS。最长 fork 回收后保留约 193 MiB,因此该上限为存活对象保留空间,同时避免较大的宿主默认堆延迟回收。该用例测量受限堆下的工作负载,不代表每种部署默认堆下的峰值。
## 校准证据
预算使用标准双 CPU 托管 CI 的期望值(Linux x64、Node 24.21、PR #5403),取高于 AMD EPYC 7763 与 EPYC 9V74 运行器中较高记录中位数的整数,再乘以共享的 1.25 余量。同一次运行内的样本差异最多 15%;两种运行器型号在单个端点上最多相差 25%。托管与参考机器的比值为 2.4 到 3.4,高于共享的参考缩放系数 2,因此按参考机器缩放会拒绝正常的托管运行。
@@ -67,6 +69,8 @@ worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新
在 Apple M5 Pro 和 Node 26.5 上的隔离运行中,1,000 个 Session 的首次列表为 311–358 ms,5,000 个 Session 的首次列表为 1,364–1,603 ms,首次搜索为 57 s,fork 最长 Session 为 6.0–6.4 s。10,000 个 Session 的首次列表耗时 3.2–5.4 s,峰值 RSS 为 1.9 GB。
未改动的最长 fork 工作负载在 Node 24.22 托管运行中,EPYC 9V74 的峰值 RSS 为 696 MiB,EPYC 7763 为 1,077.7 MiB,使用的预算均为 938 MiB。在 Apple M5 Pro、Node 24.19 上,两次默认堆运行回收后保留 192.8–193.4 MiB,峰值为 770.7–812.6 MiB,耗时 2.416–2.447 s;两次 512 MiB old-space 运行保留量相同,峰值为 764.2–764.7 MiB,耗时 2.487–2.528 s。固定上限用于控制堆策略,没有提高 RSS 或耗时预算。
## 考虑过的替代方案
- **脱敏复制真实 Session。** 已否决:基准从不使用录制的用户材料。
+11 -36
View File
@@ -1,6 +1,6 @@
---
name: record-browser-gif
description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`; publication requires gh v2.99.0 or later, and a GIF over 5 MiB is split into parts. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
---
# Record Browser GIF
@@ -17,8 +17,8 @@ The recording itself is part of the evidence: use a real server booted from that
## Keep recording separate from publication
- Recording produces local video or screenshots and one `.gif` artifact only; it never mutates remote state.
- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch.
- Recording produces local video or screenshots; encoding produces one `.gif` artifact, or its [numbered parts](#attach-with-gh) when it exceeds the 5 MiB limit. Neither mutates remote state.
- Publication — attaching the GIF to a pull request body with `gh --attach` — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never commits to any branch.
- Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
- Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
@@ -117,13 +117,15 @@ Encoder maintenance: run `python3 -m unittest discover -s "$GIF_SKILL_DIR/script
Perform this step only when the task includes attaching the GIF to a pull request.
Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media.
Never commit a GIF to the pull request's own branch or any other branch: every default clone and full-history fetch of the repository downloads media committed to any branch. Publish only with `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media.
### Attach with gh
`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching.
Before attaching, check `gh --version`. `gh --attach` requires `gh` v2.99.0 or later, a repository on github.com — GitHub Enterprise Server is not supported — and write access to the repository. When `gh` is older than v2.99.0, stop and tell the developer to upgrade `gh` to v2.99.0 or later; keep the verified GIF for the retry, and do not install or upgrade `gh` without authorization. When any other requirement is unmet or the attach fails — authentication, write access, a repository outside github.com, or an upload error — stop and report it to the developer. Never publish the GIF any other way.
Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text:
Publish GIFs at or below 5 MiB, the encoder's default `--max-bytes`. When the encoder rejects the output for size, shrink it with `--max-width`, then `--colors` or `--fps`. When the GIF still exceeds 5 MiB at readable text quality, split it instead: encode consecutive, non-overlapping intervals of the same evidence run into numbered parts that each pass the encoder's size check — `--start` and `--end` over the one video, or consecutive screenshot subsets selected with `--pattern` — and verify every part as an artifact. Beside each embed, state its position (such as `2/3`) and its source interval or frames.
Write each GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites each reference in place to its uploaded URL, keeping its position and alt text, so the order of references in the body sets the display order of split parts:
```markdown
![<alt text>](<path/to/demo.gif>)
@@ -138,35 +140,8 @@ gh pr create --body-file <body.md> --attach <path/to/demo.gif> # new pull re
gh pr edit <pr> --body-file <body.md> --attach <path/to/demo.gif> # existing pull request
```
`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
`--attach` is repeatable but refuses the same file twice. Publish split parts in one `gh pr create` or `gh pr edit` with one `--attach` per part, so a single pair of live-head checks covers every part. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
### Fall back to an assets branch
### Existing assets branches
Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `<series>-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`).
Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact.
For an existing assets branch, work in a shallow single-branch scratch clone so the publication cannot touch your working tree:
```sh
git clone --branch <assets-branch> --single-branch --depth 1 <repo-url> /tmp/assets-checkout
cp /absolute/path/to/demo.gif /tmp/assets-checkout/<name>.gif
cd /tmp/assets-checkout
git add <name>.gif
git commit -m "assets: <what it shows> gif (#<pr>)"
git push origin <assets-branch>
```
For a new series, make a fresh shallow scratch clone (`git clone --depth 1 <repo-url> /tmp/assets-checkout`), create the orphan branch with `git switch --orphan <assets-branch>`, then add the GIF, commit, and push the same way.
After pushing, use authenticated GitHub API or raw requests to confirm the remote path, byte size, checksum, `200` response, and `image/gif` content type. An anonymous `404` does not disprove a private-repository asset; authenticate the verification instead. This proves the repository-member review path, not public availability.
Immediately before editing the pull-request body, re-read the demonstrated pull request's live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the demonstrated live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `<img>` is present.
Embed the GIF in the pull request body with the raw blob URL; the `?raw=true` suffix is required, because the plain blob URL renders GitHub's file page instead of the image:
```markdown
![<alt text>](https://github.com/<owner>/<repo>/blob/<assets-branch>/<name>.gif?raw=true)
```
Never delete or rewrite an assets branch, and never force-push it: merged pull request bodies reference its URLs forever. Append new commits only.
Earlier pull request bodies embed GIFs from orphan `*-assets` branches in this repository. Never add to, delete, rewrite, or force-push them: merged pull request bodies reference their URLs.
+3 -2
View File
@@ -14,6 +14,7 @@ on:
- larger-runner-benchmark
- consolidated-runner-benchmark
- ssh-helper
- wine
# New master pushes and manual runs replace obsolete checks in this workflow/ref.
# Standby drills share cancellation; use completed runs as readiness evidence.
@@ -49,9 +50,9 @@ jobs:
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
# Wine runs once here, independently of the native serial standby aggregates.
# Wine runs on master pushes or an explicit suite=wine dispatch, independently of native standbys.
windows:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || (github.event_name == 'workflow_dispatch' && inputs.suite == 'wine')
runs-on: ubuntu-latest
name: windows node 24 / wine
timeout-minutes: 15
+83
View File
@@ -0,0 +1,83 @@
name: Package CUA helper · macOS
on:
workflow_dispatch:
inputs:
source_ref:
description: '同仓库可信源码分支或 dsh-v* tag'
type: string
default: master
required: true
preset:
description: '机器上的签名配置'
type: choice
options: [test, production]
default: test
required: true
workflow_call:
inputs:
source_ref:
type: string
required: true
preset:
type: string
default: production
outputs:
artifact:
description: '包含两个架构的已签名、公证 helper 归档'
value: ${{ jobs.package.outputs.artifact }}
permissions:
contents: read
# Desktop uses the same keychain search list and optional system proxy transaction.
concurrency:
group: macos-desktop-package
cancel-in-progress: false
jobs:
package:
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 60
outputs:
artifact: ${{ steps.artifact.outputs.name }}
env:
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
DSH_TELEMETRY_DISABLED: '1'
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
steps:
- uses: actions/[email protected]
with:
ref: ${{ inputs.source_ref }}
persist-credentials: false
clean: true
- name: Validate source selection
run: |
node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag");
const tag=ref.replace(/^refs\/tags\//, "");
if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version");
if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");'
- uses: pnpm/[email protected]
with:
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/[email protected]
with:
node-version: '24'
package-manager-cache: false
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Build and verify notarized applications
run: |
mkdir -p dist
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
- name: Name retained artifact
id: artifact
run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
- uses: actions/[email protected]
with:
name: ${{ steps.artifact.outputs.name }}
path: dist/cua-helper/*
if-no-files-found: error
compression-level: 0
retention-days: 14
+74 -4
View File
@@ -1,12 +1,28 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# publication is an explicit, reviewed act; dsh-v* tag validation is enabled by
# default and may be skipped through the dispatch input. It never appears as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
inputs:
cua_run_id:
description: '已有 CUA 打包 run ID:留空重新打包;填写时复用同一源码和版本的已验证产物'
type: string
required: false
default: ''
cua_run_attempt:
description: 'CUA 打包 attempt:仅与 run ID 一起使用;留空选择最新一次,不回退到更早的成功构建'
type: string
required: false
default: ''
skip-tag-check:
description: Skip release tag validation for the selected ref; other publication checks still apply.
type: boolean
required: false
default: false
permissions:
contents: read
@@ -16,9 +32,23 @@ env:
DSH_TELEMETRY_DISABLED: '1'
jobs:
cua-helper:
if: (startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
uses: ./.github/workflows/cua-helper-package.yml
with:
source_ref: ${{ github.ref }}
preset: production
pack:
name: Pack npm tarballs
needs: cua-helper
if: >-
${{ !cancelled() && (needs.cua-helper.result == 'success'
|| (needs.cua-helper.result == 'skipped' && (inputs.cua_run_id != '' || inputs.cua_run_attempt != ''))) }}
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
steps:
# Complete history: the release scripts read tags.
- uses: actions/[email protected]
@@ -55,10 +85,50 @@ jobs:
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
- name: Select retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
id: retained-cua
env:
GH_TOKEN: ${{ github.token }}
run: pnpm exec tsx scripts/cua-helper/select-artifact.ts
- name: Download retained CUA helper
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
uses: actions/[email protected]
with:
artifact-ids: ${{ steps.retained-cua.outputs.artifact-id }}
run-id: ${{ inputs.cua_run_id }}
github-token: ${{ github.token }}
path: dist/cua-helper
- name: Build
run: pnpm run build:official
run: pnpm run build:official --serial
- name: Download freshly built CUA helper
if: inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
uses: actions/[email protected]
with:
name: ${{ needs.cua-helper.outputs.artifact }}
path: dist/cua-helper
- name: Stage verified native helper applications
run: |
mkdir dist/cua-helper/extracted
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted
- name: Record CUA source and publication retry instructions
env:
CUA_RUN_ID: ${{ inputs.cua_run_id || github.run_id }}
CUA_RUN_ATTEMPT: ${{ steps.retained-cua.outputs.attempt || github.run_attempt }}
run: |
{
printf 'CUA helper: run `%s`, attempt `%s`.\n\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。'
printf '若需新建发布运行,填写 `cua_run_id=%s`、`cua_run_attempt=%s`,并使用相同源码 commit 和版本。\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
} >> "$GITHUB_STEP_SUMMARY"
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
+2 -34
View File
@@ -53,11 +53,6 @@ on:
type: string
default: ''
required: false
build_root:
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
type: string
default: ''
required: false
permissions:
contents: read
@@ -82,7 +77,6 @@ jobs:
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
PACKAGE_SIGNING: ${{ inputs.signing }}
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
steps:
- name: Reset packaging checkout
@@ -106,7 +100,7 @@ jobs:
run: |
$helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null
foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) {
foreach ($name in @('windows-packaging-priority.ps1')) {
Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop
}
"PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV
@@ -140,17 +134,6 @@ jobs:
- uses: actions/[email protected]
with:
node-version: '24'
- name: Select packaging workspace
id: build_workspace
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: |
if (!$env:PACKAGE_BUILD_ROOT) {
"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT
} else {
node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"
exit $LASTEXITCODE
}
- name: Configure persistent pnpm store
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
env:
@@ -167,7 +150,6 @@ jobs:
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
- name: Install locked dependencies
working-directory: ${{ steps.build_workspace.outputs.directory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))"
@@ -178,12 +160,10 @@ jobs:
exit $LASTEXITCODE
- name: Validate preset and resolve build version
id: settings
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
run: node apps/desktop/scripts/prepare-windows-ci.mjs
- name: Check packaging configuration and toolchain
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
@@ -192,23 +172,16 @@ jobs:
pnpm @packageArgs
exit $LASTEXITCODE
- name: Build installer and verify packaged runtime
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }}
run: |
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
if ($env:PACKAGE_BUILD_TEMP) {
$env:TEMP = $env:PACKAGE_BUILD_TEMP
$env:TMP = $env:PACKAGE_BUILD_TEMP
}
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Retain complete package outside the checkout
id: retained
working-directory: ${{ steps.build_workspace.outputs.directory }}
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
@@ -219,7 +192,7 @@ jobs:
with:
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain verified unsigned installer
@@ -242,8 +215,3 @@ jobs:
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Remove isolated packaging workspace
if: success() && inputs.build_root != ''
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove
+3
View File
@@ -47,3 +47,6 @@ worktrees/
.agents/worktrees/
.typert-*/
native/system/test/bin/
# Generated company-signed CUA release record.
/packages/experimental/cua-macos/cua-helper.json
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.1-rc.4",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -104,9 +104,9 @@
"@deepseek-ai/dsh-experimental-ralph-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-liveshare": "workspace:*",
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.2.1-rc.4",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -66,8 +66,8 @@
en: 182a9c6705213e13
zh: 681762f83490f4c6
/deepseek-harness-desktop/updates/local-updater-qualification:
en: 68ec2f8bd5763ea6
zh: 23b932dfe8503602
en: db5e898140a73d53
zh: 80a9d58c74b4bb24
/deepseek-harness-desktop/low-level-development-overrides:
en: 8264b135871e8978
zh: 0939aafb1b2961fc
+2
View File
@@ -353,6 +353,8 @@ The command builds the Desktop shell and runs its coordinator with real Electron
The downloaded bytes are inert, and the installation call is recorded rather than executed. The test substitutes browser opening and clipboard writing to avoid external navigation and clipboard changes. It does not boot the full product workspace, qualify a real installer or restart, verify publisher signatures, or exercise differential updates or macOS. Stalled policy requests, feed requests, and payload transfers exercise real deadlines and recovery. The actual ordinary dialog verifies isolated preload loading, card geometry, an unfiltered parent page, cancellation, task-warning choices, and explicit installation approval; account-row component tests provide separate evidence. The [local qualification decision](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.md) and [verification record](tests/README.md) preserve these limits; production release requirements remain unchanged.
Native original-file operations accept only the trusted application frame. Completion returns the destination path to that frame; revealing it uses the existing Host path-opening API without a separate reveal IPC or saved-file history. Downloads use Chromium’s URL download and native save-dialog APIs; `DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` bounds startup until Chromium creates the download (300000 ms); the native save dialog has no application deadline. Clipboard-only remote image reads omit credentials, validate every redirect, reject the application Host and its loopback aliases, stop on main-frame navigation, and use `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS` (30000 ms) and `DSH_DESKTOP_IMAGE_READ_MAX_BYTES` (67108864 bytes). Image copying uses the renderer Clipboard API after PNG conversion. Other reachable HTTP(S) destinations follow deployment network isolation. [File actions](../../packages/client/ui-file-actions/README.md) defines saving and clipboard behavior.
<a id="low-level-development-overrides"></a>
## Low-level development overrides
+2
View File
@@ -353,6 +353,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
下载内容是不可执行的测试字节,安装调用仅记录而不执行。测试替换浏览器打开与剪贴板写入,避免外部导航和剪贴板修改。它不启动完整产品工作区,不验证真实安装器或重启,不验证发布者签名,也不覆盖差分更新或 macOS。停滞的策略请求、清单请求和负载传输会执行真实截止时间及恢复。真实常规弹窗验证隔离预加载、卡片尺寸、未施加模糊的父页面、取消、任务警告选项与显式安装批准;账户行组件测试另行提供证据。[本地验证决策](../../.agents/notes/implemented/testing/2026-09-10-desktop-local-updater-qualification.zh.md)和[验证记录](tests/README.zh.md)保留这些限制;生产发布要求保持不变。
原生原始文件操作仅接受可信应用页面。完成后向该页面返回目标路径;显示位置复用现有 Host 路径打开 API,不提供独立 reveal IPC,也不保留已保存文件历史。下载使用 Chromium URL 下载和原生保存对话框 API;`DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS` 限定 Chromium 创建下载前的启动期限(300000 毫秒),不限制原生保存对话框的停留时间。仅剪贴板使用的远程图片读取不携带凭据,逐次校验重定向,拒绝应用 Host 及其回环别名,在主框架导航时终止,并使用 `DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS`(30000 毫秒)与 `DSH_DESKTOP_IMAGE_READ_MAX_BYTES`(67108864 字节)。图片复制在转换为 PNG 后使用渲染进程的 Clipboard API。其他可达的 HTTP(S) 目标遵循部署侧网络隔离。[文件操作](../../packages/client/ui-file-actions/README.zh.md) 定义保存和剪贴板行为。
<a id="low-level-development-overrides"></a>
## 底层开发覆盖项
@@ -0,0 +1,13 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download.deepseek.com",
"credentialsFile": "/absolute/path/to/packaging/secrets/cos-production.json",
"macOS": {
"signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY",
"teamId": "XXXXXXXXXX"
},
"githubApp": {
"clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID",
"privateKeyFile": "/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem"
}
}
@@ -0,0 +1,9 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download-test.example.com",
"credentialsFile": "/absolute/path/to/packaging/secrets/cos-test.json",
"macOS": {
"signingIdentity": "REPLACE_WITH_DEVELOPER_ID_APPLICATION_IDENTITY",
"teamId": "XXXXXXXXXX"
}
}
@@ -0,0 +1,12 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download.deepseek.com",
"credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-production.json",
"windows": {
"certificateThumbprint": "0000000000000000000000000000000000000000"
},
"githubApp": {
"clientId": "REPLACE_WITH_GITHUB_APP_CLIENT_ID",
"privateKeyFile": "C:/absolute/path/to/packaging/publishing/keys/desktop-release-tagger.pem"
}
}
@@ -0,0 +1,8 @@
{
"bucket": "replace-with-bucket-1250000000",
"origin": "https://download-test.example.com",
"credentialsFile": "C:/absolute/path/to/packaging/secrets/cos-test.json",
"windows": {
"certificateThumbprint": "0000000000000000000000000000000000000000"
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.2.1-rc.4",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"author": {
@@ -88,6 +88,7 @@
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"@deepseek-ai/dsh-client-ui-file-actions": "workspace:*",
"tencentcloud-sdk-nodejs-teo": "4.1.330"
}
}
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 9dd2227f2878519e
zh: cd95dd6efd48298f
en: 253e17998951931c
zh: b582b377391d2ec2
/desktop-packaging/macos-self-hosted-packaging:
en: ee628e830d849ae9
zh: 44313f5fb842f042
en: f4cc5209bdaeefba
zh: 1db959d1ed345fcc
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+2 -5
View File
@@ -115,11 +115,9 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package-<run-id>-<attempt>/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing.
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. The priority helper is copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. A missing helper stops CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression.
@@ -129,13 +127,12 @@ Installer uploads store the already-compressed release files without ZIP recompr
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing).
ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
Install the user service from the checkout as the same account that registered the runner, without `sudo`:
+2 -5
View File
@@ -115,11 +115,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package-<run-id>-<attempt>/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。
@@ -129,13 +127,12 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。
无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`:
+4 -4
View File
@@ -21,14 +21,14 @@
en: 0478d8e35f9a3d55
zh: 0aa3fd828bc02691
/desktop-ci-publication/machine-setup:
en: 6e7b928af17f4377
zh: 7c559078306df3fd
en: 0f43f2e193e95043
zh: 1c1efb062c06f7b8
/desktop-ci-publication/package-and-publish:
en: f7c12d343891e547
zh: da596ee1366c2046
/desktop-ci-publication/recovery-and-records:
en: 2e1e6b16d0d6bd41
zh: 6ff4ad5a8f1f57b4
en: b62a7484929f9941
zh: c9dd06c77098e6ef
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+4 -2
View File
@@ -54,7 +54,9 @@ For a queued job, compare the job page's requested labels with **Settings → Ac
Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases/<run-id>/<attempt>/`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published.
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Start from the [macOS test](../../config/publishing/macos/test.json.example), [macOS production](../../config/publishing/macos/production.json.example), [Windows test](../../config/publishing/windows/test.json.example), or [Windows production](../../config/publishing/windows/production.json.example) template. Create the `publishing/` directory and copy the selected file to `publishing/<preset>.json` below `DSH_PACKAGING_CONFIG_DIR`. Replace the example bucket, test origin, absolute paths, macOS signing identity and team ID or Windows certificate thumbprint, and production App client ID with the machine settings; keep the fixed production origin. Templates contain no credentials and do not grant COS, GitHub, or signing permissions. Store referenced secrets outside Git with access restricted to the publishing account. macOS uses the same configuration for arm64 and x64; the retained package determines the target.
Each configuration declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
@@ -82,7 +84,7 @@ After resolving a failure, re-run the failed publishing job to keep its prepared
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. A rejected tag creation retains its HTTP status and validated GitHub request ID; a follow-up lookup does not replace that failure with a missing-tag error. A repository-rule rejection directs the operator to the matching rulesets without logging the raw response.
Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating.
+4 -2
View File
@@ -54,7 +54,9 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions
保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases/<run-id>/<attempt>/`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
从 [macOS test](../../config/publishing/macos/test.json.example)、[macOS production](../../config/publishing/macos/production.json.example)、[Windows test](../../config/publishing/windows/test.json.example) 或 [Windows production](../../config/publishing/windows/production.json.example) 模板开始,创建 `publishing/` 目录,将所选文件复制到 `DSH_PACKAGING_CONFIG_DIR` 下的 `publishing/<preset>.json`。用机器实际配置替换示例 bucket、test origin、绝对路径、macOS 签名身份和 Team ID 或 Windows 证书指纹,以及 production App client ID;保留固定的 production origin。模板不含凭据,也不授予 COS、GitHub 或签名权限。引用的秘密文件保存在 Git 之外,只允许发布账户访问。macOS 的 arm64 和 x64 共用配置,目标由留存包决定。
每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
@@ -82,7 +84,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。tag 创建被拒绝时保留其 HTTP 状态及经过校验的 GitHub request ID,后续查询不会用 tag 不存在的错误替换创建失败。仓库规则拒绝会提示操作人员检查匹配的 ruleset,不记录原始响应。
正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。
+27 -3
View File
@@ -40,8 +40,32 @@ export async function publishDesktopTag(
body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000),
})
if (response.status === 201) { await matches(response); return 'created' }
// A parallel platform release may have created the same version tag after our first read.
if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' }
const requestId = response.headers.get('x-github-request-id')
const identity = requestId !== null && /^[A-Fa-f0-9:]{1,128}$/u.test(requestId) ? `; GitHub request ${requestId}` : ''
const failure = `desktop tag: creation failed (HTTP ${response.status}${identity})`
// A 422 can reject repository rules as well as report a concurrent tag creation.
if (response.status === 422) {
let detail: unknown
try { detail = await response.json() } catch (error) {
// Non-JSON error responses still retain the original HTTP status and request ID.
}
const rulesRejected = typeof detail === 'object' && detail !== null && 'message' in detail
&& typeof detail.message === 'string' && detail.message.startsWith('Repository rule violations found')
const guidance = rulesRejected ? 'repository rules rejected tag creation; inspect the matching tag rulesets'
: 'inspect repository rule insights and App permissions before retrying'
let reconciled: Response
try { reconciled = await get() } catch (cause) {
throw new Error(`${failure}; follow-up tag lookup failed; ${guidance}`, { cause })
}
if (!reconciled.ok) {
await reconciled.body?.cancel()
throw new Error(`${failure}; follow-up tag lookup returned HTTP ${reconciled.status}; ${guidance}`)
}
try { await matches(reconciled) } catch (cause) {
throw new Error(`${failure}; follow-up tag verification failed; inspect the existing tag before retrying`, { cause })
}
return 'present'
}
await response.body?.cancel()
throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`)
throw new Error(`${failure}; reconcile the tag before retrying`)
}
@@ -1,19 +0,0 @@
/** Identity of a Windows packaging run and its independent build root. */
export interface WindowsBuildWorkspaceOptions {
sourceDirectory: string
buildRoot: string
runId: string
runAttempt: string
sourceSha: string
}
/**
* Clone the clean selected commit into a new run-owned directory.
* @param options - Original checkout and Actions run identity.
* @returns Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string }
/**
* Remove the directory only when its ownership record matches the run.
* @param options - Original checkout and Actions run identity.
*/
export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void
@@ -1,96 +0,0 @@
/** Create and remove a run-owned checkout for Windows packaging. */
import { execFileSync } from 'node:child_process'
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
function assertUnlinkedAncestors(directory) {
for (let current = directory; ; current = dirname(current)) {
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
throw new Error('Packaging workspace ancestors must not be links')
}
if (dirname(current) === current) break
}
}
function locations(options) {
const { buildRoot, runId, runAttempt, sourceSha } = options
if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) {
throw new Error('Packaging build root must be a nonempty absolute directory')
}
const root = resolve(buildRoot)
if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root')
if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) {
throw new Error('Packaging workspace requires a run identity and exact commit')
}
assertUnlinkedAncestors(root)
for (let current = root; ; current = dirname(current)) {
if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts')
if (dirname(current) === current) break
}
const owner = join(root, `windows-package-${runId}-${runAttempt}`)
assertUnlinkedAncestors(owner)
return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') }
}
function git(directory, args) {
return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
}
function ownership(options, paths) {
return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha })
}
/**
* Clone the validated commit into a new run-owned checkout; failures retain diagnostics.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity.
* @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run.
*/
export function prepareWindowsBuildWorkspace(options) {
const paths = locations(options)
if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) {
throw new Error('Packaging source must be clean and match the selected commit')
}
mkdirSync(paths.root, { recursive: true })
mkdirSync(paths.owner)
writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' })
mkdirSync(paths.temporaryDirectory)
git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory])
git(paths.directory, ['checkout', '--detach', options.sourceSha])
if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) {
throw new Error('Isolated packaging checkout does not match the selected commit')
}
return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory }
}
/**
* Remove only the matching run-owned directory after successful artifact upload.
* Transient filesystem failures retry up to three times; persistent failures propagate.
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity.
* @returns {void}
*/
export function removeWindowsBuildWorkspace(options) {
const paths = locations(options)
const marker = join(paths.owner, 'owner.json')
const stat = lstatSync(marker)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) {
throw new Error('Packaging workspace ownership does not match this run')
}
rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 })
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
const options = {
sourceDirectory: process.env.GITHUB_WORKSPACE ?? '',
buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '',
runId: process.env.GITHUB_RUN_ID ?? '',
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '',
sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '',
}
if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options)
else {
if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments')
const result = prepareWindowsBuildWorkspace(options)
appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`)
}
}
+192
View File
@@ -0,0 +1,192 @@
/** Trusted-window Chromium downloads and clipboard fallback reads. */
import { app, ipcMain, session } from 'electron'
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
import { closeSync, openSync, rmSync } from 'node:fs'
import { extname, join } from 'node:path'
import { resolveDragFileName } from '@deepseek-ai/dsh-util-file-drag'
import { randomUUID } from 'node:crypto'
import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types'
import { DESKTOP_IPC } from './ipc.ts'
import { resolveDurationMs } from './duration-env.ts'
/** @param value - untrusted IPC input. @returns captured URL and destination policy. */
function saveRequest(value: unknown): { url: URL; name?: string; mediaType?: string; mode: 'download' | 'saveAs' } {
if (typeof value !== 'object' || value === null
|| !('url' in value) || typeof value.url !== 'string'
|| ('name' in value && typeof value.name !== 'string')
|| ('mediaType' in value && typeof value.mediaType !== 'string')
|| !('mode' in value) || (value.mode !== 'download' && value.mode !== 'saveAs')) throw new Error('Invalid file save request')
const url = new URL(value.url)
if (!['https:', 'http:', 'blob:', 'data:'].includes(url.protocol) || url.username || url.password) throw new Error('Unsupported file source')
return { url, mode: value.mode,
...'name' in value ? { name: value.name as string } : {},
...'mediaType' in value ? { mediaType: value.mediaType as string } : {},
}
}
/**
* Install trusted-window original-file operations.
* @param assertOwner - rejects shell pages, subframes, and unowned windows.
* @param hostUrl - current application Host excluded from image reads.
*/
export function installFileActions(
assertOwner: (event: IpcMainInvokeEvent) => BrowserWindow, hostUrl: () => string | undefined = () => undefined,
): void {
const timeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_IMAGE_READ_TIMEOUT_MS', 30_000)
const maxBytes = Number(process.env.DSH_DESKTOP_IMAGE_READ_MAX_BYTES ?? 64 * 1024 * 1024)
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error('DSH_DESKTOP_IMAGE_READ_MAX_BYTES must be a positive integer')
const pending = new Map<string, {
event: IpcMainInvokeEvent
owner: BrowserWindow
request: ReturnType<typeof saveRequest>
finish: (result: FileSaveResult | Error) => void
started: () => void
item?: Electron.DownloadItem
}>()
session.defaultSession.on('will-download', (_event, item, contents) => {
const url = item.getURLChain()[0]
if (url === undefined || !new URL(url).hash.startsWith('#dsh-download=')) return
const entry = pending.get(url)
if (entry === undefined || entry.owner.webContents !== contents) { item.cancel(); return }
entry.started()
entry.item = item
let reserved: string | undefined
try {
assertOwner(entry.event)
const name = resolveDragFileName({
name: entry.request.name ?? item.getFilename(),
mediaType: item.getMimeType() || entry.request.mediaType || '',
})
let destination = join(app.getPath('downloads'), name)
if (entry.request.mode === 'saveAs') {
item.setSaveDialogOptions({ defaultPath: destination, properties: ['showOverwriteConfirmation', 'createDirectory'] })
} else {
const extension = extname(destination)
const stem = destination.slice(0, destination.length - extension.length)
for (let index = 0; ; index++) {
destination = index === 0 ? `${stem}${extension}` : `${stem} (${index})${extension}`
try { closeSync(openSync(destination, 'wx')); break }
catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') continue
throw error
}
}
reserved = destination
item.setSavePath(destination)
}
item.once('done', (_event, state) => {
try {
if (state !== 'completed') {
if (reserved !== undefined) rmSync(reserved, { force: true })
entry.finish(state === 'cancelled' ? { status: 'cancelled' } : new Error('File download interrupted'))
return
}
if (!pending.has(url)) return
assertOwner(entry.event)
entry.finish({ status: 'saved', path: item.getSavePath() })
} catch (error) { entry.finish(error instanceof Error ? error : new Error('File download unavailable')) }
})
} catch (error) {
item.cancel()
try { if (reserved !== undefined) rmSync(reserved, { force: true }) }
catch (cleanupError) {
entry.finish(new AggregateError([error, cleanupError], 'File download failed and its partial file could not be removed'))
return
}
entry.finish(error instanceof Error ? error : new Error('File download unavailable'))
}
})
const downloadTimeoutMs = resolveDurationMs(process.env, 'DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', 300_000)
ipcMain.handle(DESKTOP_IPC.fileSave, (event, value: unknown): Promise<FileSaveResult> => {
const owner = assertOwner(event)
const request = saveRequest(value)
// Fragments do not change the fetched resource; each native request has its own completion identity.
request.url.hash = `dsh-download=${randomUUID()}`
const url = request.url.href
return new Promise((resolve, reject) => {
const contents = owner.webContents
const finish = (result: FileSaveResult | Error): void => {
if (!pending.delete(url)) return
clearTimeout(timer)
owner.removeListener('closed', cancel)
contents.removeListener('did-start-navigation', navigate)
if (result instanceof Error) reject(result)
else resolve(result)
}
const cancel = (): void => {
const item = pending.get(url)?.item
finish({ status: 'cancelled' })
item?.cancel()
}
const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => {
if (mainFrame && !inPlace) cancel()
}
const timer = setTimeout(() => {
const item = pending.get(url)?.item
finish(new Error('File download timed out'))
item?.cancel()
}, downloadTimeoutMs)
pending.set(url, { event, owner, request, finish, started: () => { clearTimeout(timer) } })
owner.once('closed', cancel)
contents.on('did-start-navigation', navigate)
try { contents.downloadURL(url) }
catch (error) { finish(error instanceof Error ? error : new Error('File download unavailable')) }
})
})
ipcMain.handle(DESKTOP_IPC.clipboardImageRead, async (event, value: unknown) => {
const owner = assertOwner(event)
if (typeof value !== 'string') throw new Error('Invalid image URL')
const checkedUrl = (value: string): URL => {
const url = new URL(value)
const host = hostUrl()
const application = host === undefined ? undefined : new URL(host)
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password
|| application !== undefined && url.port === application.port
&& (url.hostname === application.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
throw new Error('Invalid image URL')
}
return url
}
let url = checkedUrl(value)
const contents = owner.webContents
const lifetime = new AbortController()
const abort = (): void => { lifetime.abort() }
owner.once('closed', abort)
const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean): void => {
if (mainFrame && !inPlace) abort()
}
contents.on('did-start-navigation', navigate)
try {
const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(timeoutMs)])
let response: Response
for (let redirects = 0; ; redirects++) {
response = await fetch(url, { signal, redirect: 'manual', credentials: 'omit', referrerPolicy: 'no-referrer' })
if (![301, 302, 303, 307, 308].includes(response.status)) break
await response.body?.cancel()
const location = response.headers.get('location')
if (redirects >= 20 || location === null) throw new Error('Invalid image redirect')
url = checkedUrl(new URL(location, url).href)
}
if (!response.ok) throw new Error('Original image unavailable')
const reader = response.body?.getReader()
if (reader === undefined) throw new Error('Original image unavailable')
const chunks: Uint8Array[] = []
let size = 0
try {
for (;;) {
const chunk = await reader.read()
if (chunk.done) break
size += chunk.value.byteLength
if (size > maxBytes) throw new Error('Original image exceeds the download limit')
chunks.push(chunk.value)
}
} finally { await reader.cancel(); reader.releaseLock() }
assertOwner(event)
lifetime.signal.throwIfAborted()
return { bytes: new Uint8Array(Buffer.concat(chunks)), mediaType: response.headers.get('content-type') ?? '' }
} finally {
owner.removeListener('closed', abort)
contents.removeListener('did-start-navigation', navigate)
}
})
}
+4
View File
@@ -1,3 +1,4 @@
import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types'
/** Typed preload operations exposed only by the Electron shell. */
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
@@ -7,6 +8,8 @@ import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag'
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
fileSave: 'dsh-desktop:file-save',
clipboardImageRead: 'dsh-desktop:clipboard-image-read',
shortcutsInput: 'dsh-desktop:shortcuts-input',
shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window',
shortcutsGet: 'dsh-desktop:shortcuts-get',
@@ -76,6 +79,7 @@ export interface DesktopUpdatePresentation {
/** Product documents cannot supply update versions, package URLs, or installation authorization. */
export interface DshDesktopProductApi {
readonly fileActions: DesktopFileActions
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly fileDrag: DesktopFileDrag
+2
View File
@@ -1,3 +1,4 @@
import { installFileActions } from './file-actions.ts'
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
@@ -826,6 +827,7 @@ async function main(): Promise<void> {
|| !event.senderFrame.url.startsWith('dsh-app://app/')) throw new Error('Rejected Platform command')
return owner
}
installFileActions(assertMainApplication, () => hostUrl)
ipcMain.on(PLATFORM_IPC.bootstrap, (event) => {
try { event.returnValue = platformView.bootstrap(event) }
catch { event.returnValue = null }
+6
View File
@@ -1,3 +1,4 @@
import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types'
/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */
import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
@@ -14,6 +15,11 @@ import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
function createProductApi(): DshDesktopProductApi {
return {
protocolVersion: 1,
fileActions: {
save: request => ipcRenderer.invoke(DESKTOP_IPC.fileSave, request) as Promise<FileSaveResult>,
readClipboardImage: url => ipcRenderer.invoke(DESKTOP_IPC.clipboardImageRead, url) as
Promise<{ bytes: Uint8Array; mediaType: string }>,
},
browser: createDesktopBrowserBridge(),
fileDrag: {
prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as
+15 -11
View File
@@ -1,4 +1,4 @@
/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */
/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */
import { createHash } from 'node:crypto'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -37,18 +37,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
const cli = await readManifest('apps/cli')
const bundle = await readManifest('packages/experimental/cua-bundle')
const native = await readManifest('packages/experimental/cua-macos')
expect(cli.dependencies).toHaveProperty(bundleName)
expect(cli.dependencies).not.toHaveProperty(bundleName)
expect(bundle.dependencies).not.toHaveProperty(providerName)
expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' })
expect(native.os).toEqual(['darwin'])
const version = '1.0.0'
const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record<string, string>).map(name => [name, version]))
const manifests = [
{ name: cli.name, version, dependencies: { [bundleName]: version } },
{ name: cli.name, version },
{ name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } },
...Object.keys(bundleDependencies).map(name => ({ name, version })),
{ name: providerName, version, os: native.os, files: native.files },
]
const generation = 'a'.repeat(64)
const nativePayload = `native/bin/darwin-arm64/${generation}/DSH Computer Use.app/Contents/MacOS/dsh-cua-native`
const packages = new Map<string, { manifest: Record<string, unknown>; bytes: Buffer }>()
for (const manifest of manifests) {
const name = String(manifest.name)
@@ -56,18 +58,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await mkdir(directory)
await writeFile(join(directory, 'package.json'), JSON.stringify(manifest))
if (name === providerName) {
await mkdir(join(directory, 'native/bin/darwin-arm64'), { recursive: true })
await mkdir(join(directory, nativePayload, '..'), { recursive: true })
for (const file of ['.gitignore', '.npmignore']) {
await writeFile(join(directory, 'native', file), await readFile(join(repository, 'packages/experimental/cua-macos/native', file)))
}
await writeFile(join(directory, 'native/bin/darwin-arm64/dsh-cua-native'), 'inert native payload')
await writeFile(join(directory, 'native/bin/darwin-arm64/current.json'), JSON.stringify({ bundle: generation }))
await writeFile(join(directory, nativePayload), 'inert native payload')
}
await command(['pack', '--out', 'package.tgz'], directory)
const bytes = await readFile(join(directory, 'package.tgz'))
packages.set(name, { manifest, bytes })
if (name === providerName) {
const result = await execa('tar', ['-tzf', join(directory, 'package.tgz')])
expect(result.stdout).toContain('package/native/bin/darwin-arm64/dsh-cua-native')
expect(result.stdout).toContain('package/native/bin/darwin-arm64/current.json')
expect(result.stdout).toContain(`package/${nativePayload}`)
}
}
const requests: string[] = []
@@ -98,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address')
for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) {
for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) {
unrestricted = target === 'linux-unrestricted'
const project = join(root, target)
await mkdir(project)
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } }))
await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } }))
await writeFile(join(project, 'pnpm-workspace.yaml'), [
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`,
'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`,
' cpu: [arm64]', 'minimumReleaseAge: 0', '',
].join('\n'))
requests.length = 0
@@ -113,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
`--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`,
`--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`,
], project)
expect(requests).toContain(`/${bundleName}.tgz`)
expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz')
expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base')
expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base')
expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`)
.toBe(target === 'darwin' || unrestricted)
}
+35 -2
View File
@@ -3,13 +3,13 @@ import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts'
const commit = 'a'.repeat(40)
const tag = (sha = commit) => ({ object: { type: 'commit', sha } })
function transport(replies: Array<{ status: number; body?: object }>) {
function transport(replies: Array<{ status: number; body?: object; rawBody?: string; headers?: Record<string, string> }>) {
const calls: Array<{ url: string; init: RequestInit | undefined }> = []
const request: typeof fetch = async (url, init) => {
calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init })
const reply = replies.shift()
if (!reply) throw new Error('unexpected HTTP request')
return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status })
return new Response(reply.rawBody ?? JSON.stringify(reply.body ?? {}), { status: reply.status, headers: reply.headers ?? {} })
}
return { calls, request }
}
@@ -57,3 +57,36 @@ it('explains an existing annotated tag without replacing it', async () => {
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u)
expect(http.calls).toHaveLength(1)
})
it('keeps the creation status and safe rule diagnostic when no competing tag exists', async () => {
const http = transport([{ status: 404 }, { status: 422,
headers: { 'x-github-request-id': 'ABCD:1234' },
body: { message: 'Repository rule violations found for refs/tags/desktop-v1.2.3', errors: ['secret-value'] } }, { status: 404 }])
const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)
await expect(failure).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup returned HTTP 404; repository rules rejected')
await expect(failure).rejects.not.toThrow('secret-value')
expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET'])
})
it('does not echo arbitrary GitHub response text or unrecognized request IDs', async () => {
const http = transport([{ status: 404 }, { status: 422,
headers: { 'x-github-request-id': 'fixture-token' }, body: { message: 'fixture-token' } }, { status: 403 }])
const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)
await expect(failure).rejects.toThrow('creation failed (HTTP 422); follow-up tag lookup returned HTTP 403')
await expect(failure).rejects.not.toThrow('fixture-token')
})
it('still rejects a competing tag on a different commit', async () => {
const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag('b'.repeat(40)) }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422)')
})
it('retains creation context for a non-JSON rejection', async () => {
const http = transport([{ status: 404 }, { status: 422, rawBody: 'not JSON', headers: { 'x-github-request-id': 'ABCD:1234' } }, { status: 404 }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234)')
})
it('retains creation context when the follow-up request fails', async () => {
const http = transport([{ status: 404 }, { status: 422, headers: { 'x-github-request-id': 'ABCD:1234' } }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup failed')
})
@@ -0,0 +1,35 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, it } from 'vitest'
import { readDesktopPublishConfig, type DesktopPublishConfig } from '../scripts/desktop-publish-config.ts'
for (const platform of ['macos', 'windows'] as const) {
for (const preset of ['test', 'production'] as const) {
it(`accepts the ${platform} ${preset} template after machine paths are supplied`, async () => {
const path = fileURLToPath(new URL(`../config/publishing/${platform}/${preset}.json.example`, import.meta.url))
const template = JSON.parse(await readFile(path, 'utf8')) as DesktopPublishConfig
const paths = platform === 'windows' ? win32 : posix
expect(paths.isAbsolute(template.credentialsFile)).toBe(true)
expect(template[platform === 'macos' ? 'macOS' : 'windows']).toBeDefined()
expect(Object.hasOwn(template, 'secretId')).toBe(false)
expect(Object.hasOwn(template, 'secretKey')).toBe(false)
if (preset === 'production') expect(paths.isAbsolute(template.githubApp?.privateKeyFile ?? '')).toBe(true)
const root = await mkdtemp(join(tmpdir(), 'publish-template-'))
try {
await mkdir(join(root, 'publishing'))
// The parser uses the current host's path syntax; examples retain their target platform's paths.
const config = { ...template, credentialsFile: join(root, 'absent-credentials.json'),
...(template.githubApp ? { githubApp: { ...template.githubApp, privateKeyFile: join(root, 'absent-key.pem') } } : {}) }
const output = join(root, 'publishing', `${preset}.json`)
await writeFile(output, JSON.stringify(config))
await expect(readDesktopPublishConfig(root, preset)).resolves.toEqual(config)
await writeFile(output, JSON.stringify({ ...config, bucket: undefined }))
const failure = readDesktopPublishConfig(root, preset)
await expect(failure).rejects.toHaveProperty('name', 'ZodError')
await expect(failure).rejects.toHaveProperty('issues.0.path', ['bucket'])
} finally { await rm(root, { recursive: true, force: true }) }
})
}
}
+213
View File
@@ -0,0 +1,213 @@
/** Native original-byte saves and window-scoped IPC ownership. */
import { EventEmitter } from 'node:events'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { IpcMainInvokeEvent } from 'electron'
import { afterEach, expect, it, vi } from 'vitest'
import { installFileActions } from '../src/file-actions.ts'
import { DESKTOP_IPC } from '../src/ipc.ts'
const electron = vi.hoisted(() => ({
app: { getPath: vi.fn() }, ipcMain: { handle: vi.fn() }, session: { defaultSession: { on: vi.fn() } },
}))
vi.mock('electron', () => electron)
const roots: string[] = []
const owners: EventEmitter[] = []
afterEach(async () => {
for (const owner of owners.splice(0)) owner.emit('closed')
vi.useRealTimers(); vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))); vi.clearAllMocks(); vi.unstubAllGlobals()
})
async function directory() { const path = await mkdtemp(join(tmpdir(), 'dsh-file-actions-')); roots.push(path); return path }
function harness(root: string) {
const owner = Object.assign(new EventEmitter(), {
webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false,
})
owners.push(owner)
const assertOwner = vi.fn(() => owner as never)
electron.app.getPath.mockReturnValue(root)
installFileActions(assertOwner)
const event = {} as IpcMainInvokeEvent
const invoke = async (channel: string, value: unknown): Promise<unknown> => {
const handler = electron.ipcMain.handle.mock.calls.find(([name]) => name === channel)?.[1] as
(event: IpcMainInvokeEvent, value: unknown) => Promise<unknown>
return handler(event, value)
}
const item = (mime = 'image/gif', url = owner.webContents.downloadURL.mock.lastCall![0]) => {
let destination = ''
const download = Object.assign(new EventEmitter(), {
getURLChain: () => [url],
getFilename: () => 'original.gif', getMimeType: () => mime, getSavePath: () => destination,
setSavePath: vi.fn((path: string) => { destination = path }),
setSaveDialogOptions: vi.fn(), cancel: vi.fn(),
})
const willDownload = electron.session.defaultSession.on.mock.calls[0]![1] as
(event: object, item: object, contents: object) => void
willDownload({}, download, owner.webContents)
return download
}
const save = async (mode: 'download' | 'saveAs' = 'download') => {
const pending = invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/original.gif', name: 'image.gif', mode })
const download = item()
if (mode === 'download') await writeFile(download.getSavePath(), new Uint8Array([0, 128, 255]))
download.emit('done', {}, 'completed')
return pending
}
return { owner, assertOwner, invoke, item, save }
}
it('returns the completed destination and releases download listeners', async () => {
const root = await directory(), f = harness(root)
await expect(f.save()).resolves.toEqual({ status: 'saved', path: join(root, 'image.gif') })
expect(f.owner.listenerCount('closed')).toBe(0)
expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0)
})
it.each(['file:///private/data', 'dsh-app://app/api/private', 'javascript:alert(1)'])('rejects unsupported URL %s', async (url) => {
const root = await directory(), f = harness(root)
await expect(f.invoke(DESKTOP_IPC.fileSave, { url, mode: 'download' })).rejects.toThrow('Unsupported')
expect(await readdir(root)).toEqual([])
})
it('retains original downloads and reserves distinct paths until Chromium finishes', async () => {
const root = await directory(), f = harness(root)
await writeFile(join(root, 'image.gif'), 'previous')
await f.save()
expect(await readFile(join(root, 'image.gif'), 'utf8')).toBe('previous')
expect(await readFile(join(root, 'image (1).gif'))).toEqual(Buffer.from([0, 128, 255]))
})
it.each(['cancelled', 'interrupted'] as const)('reports native %s without a completed path', async (state) => {
const root = await directory(), f = harness(root)
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'wrong.png', mode: 'download' })
const download = f.item('image/webp')
expect(download.setSavePath).toHaveBeenCalledWith(join(root, 'wrong.webp'))
download.emit('done', {}, state)
if (state === 'cancelled') await expect(pending).resolves.toEqual({ status: 'cancelled' })
else await expect(pending).rejects.toThrow('interrupted')
expect(await readdir(root)).toEqual([])
})
it('uses the native save dialog and waits for completion before reporting success', async () => {
const root = await directory(), f = harness(root)
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'photo.png', mode: 'saveAs' })
const download = f.item('image/jpeg')
expect(download.setSavePath).not.toHaveBeenCalled()
expect(download.setSaveDialogOptions).toHaveBeenCalledWith({ defaultPath: join(root, 'photo.jpg'), properties: ['showOverwriteConfirmation', 'createDirectory'] })
download.emit('done', {}, 'cancelled')
await expect(pending).resolves.toEqual({ status: 'cancelled' })
f.assertOwner.mockImplementation(() => { throw new Error('Rejected owner') })
await expect(f.invoke(DESKTOP_IPC.fileSave, {})).rejects.toThrow('Rejected owner')
})
it('cancels pending downloads when the owning document changes', async () => {
const f = harness(await directory())
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })
const download = f.item()
f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true)
await expect(pending).resolves.toEqual({ status: 'cancelled' })
expect(download.cancel).toHaveBeenCalledOnce()
download.emit('done', {}, 'cancelled')
expect(f.owner.listenerCount('closed')).toBe(0)
})
it('matches overlapping downloads of the same source to their captured requests', async () => {
const root = await directory(), f = harness(root)
const first = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'first.gif', mode: 'download' })
const firstUrl = f.owner.webContents.downloadURL.mock.lastCall![0]
const second = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', name: 'second.gif', mode: 'saveAs' })
const secondItem = f.item()
const firstItem = f.item('image/gif', firstUrl)
expect(firstItem.setSavePath).toHaveBeenCalledWith(join(root, 'first.gif'))
expect(secondItem.setSaveDialogOptions.mock.lastCall?.[0]).toMatchObject({ defaultPath: join(root, 'second.gif') })
secondItem.emit('done', {}, 'cancelled')
firstItem.emit('done', {}, 'completed')
await expect(first).resolves.toMatchObject({ status: 'saved' })
await expect(second).resolves.toEqual({ status: 'cancelled' })
})
it('retires timed-out downloads and cancels items arriving after their window closes', async () => {
vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000')
const root = await directory(), f = harness(root)
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })
const rejected = expect(pending).rejects.toThrow('timed out')
await vi.advanceTimersByTimeAsync(1000)
await rejected
const download = f.item()
expect(download.cancel).toHaveBeenCalledOnce()
download.emit('done', {}, 'cancelled')
expect(await readdir(root)).toEqual([])
expect(f.owner.listenerCount('closed')).toBe(0)
expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0)
const late = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })
f.owner.emit('closed')
await expect(late).resolves.toEqual({ status: 'cancelled' })
const stale = f.item()
expect(stale.cancel).toHaveBeenCalledOnce()
expect(stale.setSavePath).not.toHaveBeenCalled()
})
it('allows the native save dialog to remain open beyond the download startup deadline', async () => {
vi.useFakeTimers(); vi.stubEnv('DSH_DESKTOP_FILE_DOWNLOAD_TIMEOUT_MS', '1000')
const f = harness(await directory())
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' })
const download = f.item()
await vi.advanceTimersByTimeAsync(2000)
expect(download.cancel).not.toHaveBeenCalled()
download.emit('done', {}, 'completed')
await expect(pending).resolves.toMatchObject({ status: 'saved' })
})
it('reports a synchronous Chromium launch failure and releases pending listeners', async () => {
const f = harness(await directory())
f.owner.webContents.downloadURL.mockImplementation(() => { throw new Error('Download unavailable') })
await expect(f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'download' })).rejects.toThrow('Download unavailable')
expect(f.owner.listenerCount('closed')).toBe(0)
expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0)
})
it('aborts original-image reads on window navigation and releases lifecycle listeners', async () => {
const f = harness(await directory())
let signal!: AbortSignal
vi.stubGlobal('fetch', vi.fn((_url: URL, options: RequestInit) => new Promise<Response>((_resolve, reject) => {
signal = options.signal!
signal.addEventListener('abort', () => { reject(new DOMException('Aborted', 'AbortError')) }, { once: true })
})))
const reading = f.invoke(DESKTOP_IPC.clipboardImageRead, 'https://images.example/original.gif')
f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/', false, true)
await expect(reading).rejects.toThrow('Aborted')
expect(signal.aborted).toBe(true)
expect(f.owner.listenerCount('closed')).toBe(0)
expect(f.owner.webContents.listenerCount('did-start-navigation')).toBe(0)
})
it('preserves pending downloads across iframe and same-document navigation', async () => {
const root = await directory(), f = harness(root)
const pending = f.invoke(DESKTOP_IPC.fileSave, { url: 'https://images.example/a', mode: 'saveAs' })
const download = f.item()
f.owner.webContents.emit('did-start-navigation', {}, 'https://example.org', false, false)
f.owner.webContents.emit('did-start-navigation', {}, 'dsh-app://app/#tab', true, true)
download.setSavePath(join(root, 'chosen.gif'))
download.emit('done', {}, 'completed')
await expect(pending).resolves.toEqual({ status: 'saved', path: join(root, 'chosen.gif') })
expect(download.cancel).not.toHaveBeenCalled()
})
it('rejects redirects to the application Host', async () => {
const root = await directory()
const owner = Object.assign(new EventEmitter(), {
webContents: Object.assign(new EventEmitter(), { downloadURL: vi.fn<(url: string) => void>() }), isDestroyed: () => false,
})
installFileActions(() => owner as never, () => 'http://127.0.0.1:19321')
const handler = (name: string) => electron.ipcMain.handle.mock.calls.find(([channel]) => channel === name)![1] as
(event: IpcMainInvokeEvent, value: unknown) => Promise<unknown>
const fetchImage = vi.fn(async () => new Response(null, { status: 302, headers: { location: 'http://localhost:19321/api/private' } }))
vi.stubGlobal('fetch', fetchImage)
const event = {} as IpcMainInvokeEvent
await expect(handler(DESKTOP_IPC.clipboardImageRead)(event, 'https://images.example/photo')).rejects.toThrow('Invalid image URL')
expect(fetchImage).toHaveBeenCalledOnce()
expect(await readdir(root)).toEqual([])
})
+1
View File
@@ -317,6 +317,7 @@ vi.mock('electron', () => ({
},
Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu },
session: { defaultSession: {
on: vi.fn(),
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
+13
View File
@@ -38,6 +38,19 @@ it('exposes onboarding size activation to the application document', async () =>
expect(electron.ipcRenderer.send.mock.calls).toEqual([[DESKTOP_IPC.onboardingActive, true], [DESKTOP_IPC.onboardingActive, false]])
})
it('routes original-file requests through the trusted application bridge', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/'))
await import('../src/preload-app.ts')
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
const request = { url: 'https://images.example/original.gif', name: 'image.gif', mode: 'download' as const }
await api.fileActions.save(request)
await api.fileActions.readClipboardImage('https://images.example/original.gif')
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
[DESKTOP_IPC.fileSave, request],
[DESKTOP_IPC.clipboardImageRead, 'https://images.example/original.gif'],
])
})
it('limits product documents to update status and a native confirmation action', async () => {
vi.stubGlobal('location', new URL('dsh-app://app/index.html'))
await import('../src/preload-app.ts')
@@ -87,6 +87,7 @@ vi.mock('electron', () => ({
net: { fetch: vi.fn() },
nativeTheme: state.nativeTheme,
session: { defaultSession: {
on: vi.fn(),
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() },
+3
View File
@@ -18,6 +18,9 @@
},
{
"path": "../../packages/client/product-analytics/tsconfig.client.json"
},
{
"path": "../../packages/client/ui-file-actions"
}
]
}
+3
View File
@@ -40,6 +40,9 @@
},
{
"path": "../../packages/util/file-drag"
},
{
"path": "../../packages/client/ui-file-actions"
}
],
"exclude": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-plugin-market-generator",
"description": "Reference generator of plugin market index files from a list of npm packages and Git repositories",
"version": "0.2.1-rc.4",
"version": "0.2.2-alpha.2",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.2.1-rc.4",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
+7 -4
View File
@@ -21,8 +21,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
document.documentElement.setAttribute('data-windows-titlebar', '')
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
})
const reopenSidebar = page.getByRole('button', { name: '打开侧边栏', exact: true })
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
await page.setViewportSize({ width: 521, height: 600 })
await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
const reopenSidebar = page.locator('[data-shell-leading]').getByRole('button', { name: '打开侧边栏', exact: true })
await openSettings(page, 'zh')
const settings = page.getByRole('dialog', { name: '设置', exact: true })
const settingsMask = settings.locator('..').locator(':scope > [aria-hidden="true"]')
@@ -51,7 +52,7 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
await menu.waitFor({ state: 'hidden' })
expect(await settings.isVisible()).toBe(true)
await settings.getByRole('button', { name: '关闭', exact: true }).click()
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) await reopenSidebar.click()
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
await page.locator('[data-plugin-panel]').getByRole('button', { name: '添加插件', exact: true }).click()
const plugin = page.getByRole('dialog', { name: '添加插件', exact: true })
@@ -88,7 +89,9 @@ it('keeps settings and expanded plugin dialogs clear of the Windows caption acro
if (value === 'darwin') document.documentElement.setAttribute('data-platform', value)
else document.documentElement.removeAttribute('data-platform')
}, platform)
if (await reopenSidebar.isVisible()) await reopenSidebar.click()
// Publish the synthetic platform change through the frame's size observer.
await page.setViewportSize({ width: platform === 'darwin' ? 520 : 521, height: 600 })
await page.locator('[data-sidebar-drawer]').waitFor()
await openSettings(page, 'zh')
await expect.poll(async () => (await settings.boundingBox())?.y).toBe(platform === 'darwin' ? 68 : 0)
expect((await settingsMask.boundingBox())?.y).toBe(0)
+3 -3
View File
@@ -309,6 +309,9 @@ async function loadedFlowRows(page: Page): Promise<number> {
}
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
if (await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').count() > 0) {
await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
}
// Search collapsed into a header action; expand it before filling.
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
@@ -1700,9 +1703,6 @@ describe('web e2e: long Chat scroll contract', () => {
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
await world.page.setViewportSize({ width: 700, height: 900 })
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
// this scenario switches sessions while pinning the narrow Chat scroll owner.
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
await nextPaint(world.page)
await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
+18 -15
View File
@@ -26,7 +26,7 @@ async function openInventory(page: Page, url: string) {
return dialog
}
it('places dynamic Session menu rows by order among the shipped ones and removes them with their fiber', async () => {
it('keeps ordered legacy Session menu rows before Archive and removes them with their fiber', async () => {
const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
onTestFinished(() => scaffold.close())
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
@@ -46,12 +46,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
const trailing = row.locator('[data-session-trailing]')
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row,
timeVisible = iconsVisible): Promise<void> => {
const icons = target.locator('[data-session-trailing]')
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const time = target.locator('[class*="time"]')
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(timeVisible)
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
observations.push({
stage,
@@ -94,14 +95,16 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await observe('menu-open', false, true)
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('separator').count()).toBe(1)
expect(await menu.getByRole('separator').count()).toBe(3)
const exportRow = menu.getByRole('menuitem', { name: 'Export session', exact: true })
expect(await exportRow.locator('..').getByRole('separator').isVisible()).toBe(true)
await compareOrRefreshGolden(
SESSION_ACTION_EXPECTED,
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd),
webSnapshotMode(),
)
await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter')
await exportRow.press('Enter')
await expect.poll(() => menu.count()).toBe(0)
expect(await page.evaluate(() => ({
action: document.documentElement.dataset.sessionAction,
@@ -113,7 +116,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await page.mouse.move(0, 0)
await observe('blur', true, false)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await page.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
await page.mouse.move(0, 0)
await observe('narrow', true, false)
const bounds = await row.evaluate((element) => {
@@ -133,23 +136,23 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
try {
const touchConsole = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await touchPage.locator('[data-shell-leading]').getByRole('button', { name: 'Open sidebar', exact: true }).click()
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
await touchTrigger.waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
await observe('touch', true, true, touchRow)
await observe('touch', true, true, touchRow, false)
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
await touchTrigger.tap()
await touchPage.getByRole('menu').waitFor()
await observe('touch-menu-open', true, true, touchRow)
await observe('touch-menu-open', true, true, touchRow, false)
await touchPage.keyboard.press('Escape')
await observe('touch-menu-closed', true, true, touchRow)
await observe('touch-menu-closed', true, true, touchRow, false)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow)
await touchRow.getByRole('button', { name: 'Unpin session', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow, false)
const touchBounds = await touchRow.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
@@ -173,7 +176,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-archived-row', true, false)
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
await observe('touch-unarchived', true, true, touchRow)
await observe('touch-unarchived', true, true, touchRow, false)
expect(touchConsole.pageErrors).toEqual([])
} finally {
await touchPage.close()
@@ -193,13 +196,13 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.hover()
await trigger.click()
await menu.waitFor()
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID']
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Archive session']
const remainingItems = menu.getByRole('menuitem')
expect(await remainingItems.count()).toBe(remainingNames.length)
for (const [index, name] of remainingNames.entries()) {
expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1)
}
expect(await menu.getByRole('separator').count()).toBe(0)
expect(await menu.getByRole('separator').count()).toBe(2)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click()
await expect.poll(() => menu.count()).toBe(0)
+386
View File
@@ -0,0 +1,386 @@
/** Context gestures against built plugins, seeded Sessions, and original files. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chromium, type Browser, type Locator, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const GIF = Buffer.from('R0lGODdhMAAgAIEAAEtu5gAAAAAAAAAAACwAAAAAMAAgAEAIQQABCBxIsKDBgwgTKlzIsKHDhxAjSpxIsaLFixgzatzIsaPHjyBDihxJsqTJkyhTqlzJsqXLlzBjypxJs6bNmQEBADs=', 'base64')
async function menuLabels(menu: Locator): Promise<string[]> {
return menu.getByRole('menuitem').evaluateAll(items => items.map((item) => {
const copy = item.cloneNode(true) as HTMLElement
for (const decoration of copy.querySelectorAll('[aria-hidden="true"]')) decoration.remove()
return copy.textContent ?? ''
}))
}
function fixture(title: string): string {
const session = Session.create(SessionId('context-menus-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Show the output.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
session.append('session/title', { title, messageSeqs: [user.seq], source: { kind: 'fallback' } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createAssistantMessage({
content: [{ type: 'text', text: '[First file](first.txt#L2) [Second file](second.txt) [Website](https://example.com/path?q=1#fragment)\n\n![Original](original.gif) ![Remote](https://images.example/original.gif)\n\nCONTEXT_MENU_READY' }],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}) }, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return [JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }),
...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: 1_000 + event.seq * 1_000 })), ''].join('\n')
}
describe.each(['MacIntel', 'Win32', 'Linux x86_64'])('web context menus on %s', (platform) => {
let scaffold: WebScaffold, browser: Browser, page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]) })
await mkdir(join(scaffold.workspaceCwd, 'folder'))
await scaffold.ctx.workspaceRegistry.create(join(scaffold.workspaceCwd, 'folder'), 'Menu workspace')
await writeFile(join(scaffold.workspaceCwd, 'first.txt'), 'FIRST\nSECOND\nTHIRD\n')
await writeFile(join(scaffold.workspaceCwd, 'second.txt'), 'SECOND FILE\n')
await writeFile(join(scaffold.workspaceCwd, 'original.gif'), GIF)
await seedSession(scaffold, fixture('Menu foreground'), 'context-menu-first')
await seedSession(scaffold, fixture('Menu background'), 'context-menu-other')
browser = await chromium.launch()
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
page = await context.newPage()
await page.setViewportSize({ width: 1291, height: 768 })
tripwire = watchConsole(page)
await page.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif', headers: { 'access-control-allow-origin': '*' } }))
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto(scaffold.authenticatedUrl)
await page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' }).click()
await page.locator('[data-row-key="session:context-menu-other"]').click()
await page.locator('[data-row-key="session:context-menu-other"]').getByText('Menu background', { exact: true }).waitFor()
await page.locator('[data-row-key="session:context-menu-first"]').click()
await page.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor()
})
afterAll(async () => { await browser?.close(); await scaffold?.close() })
it('shares Session entries without selecting the background Session, and dismisses at viewport edges', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-session'))
const background = page.locator('[data-row-key="session:context-menu-other"]')
await background.click({ button: 'right' })
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
const names = await menuLabels(menu)
expect(names.slice(0, 5)).toEqual(['Pin session', 'Rename', 'Fork session', 'Copy Session ID', 'Show in folder'])
expect(names.at(-1)).toBe('Archive session')
expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true)
expect(await menu.getByRole('separator').count()).toBe(2)
expect(await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).evaluate(row =>
getComputedStyle(row.parentElement!.parentElement!).borderTopWidth)).toBe('0px')
const alignedSeparators = async () => {
const bounds = await menu.evaluate((node) => {
const row = node.querySelector<HTMLElement>('[role="menuitem"]')!
const rect = row.getBoundingClientRect()
const style = getComputedStyle(row)
return {
left: rect.left + Number.parseFloat(style.paddingLeft),
right: rect.right - Number.parseFloat(style.paddingRight),
lines: [...node.querySelectorAll('[role="separator"]')].map((line) => {
const rect = line.getBoundingClientRect()
return { left: rect.left, right: rect.right }
}),
}
})
for (const line of bounds.lines) {
expect(line.left).toBeCloseTo(bounds.left, 1)
expect(line.right).toBeCloseTo(bounds.right, 1)
}
}
await alignedSeparators()
await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/session.txt'))
expect(names.some(name => name.includes('Rename'))).toBe(true)
expect(await background.getAttribute('aria-selected')).toBe('false')
await page.keyboard.press('Escape')
await background.getByRole('button', { name: /actions/i }).click()
await menu.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
expect(await menuLabels(menu)).toEqual(names)
await alignedSeparators()
await background.getByRole('button', { name: /actions/i }).click()
expect(await menu.count()).toBe(0)
expect(await background.getAttribute('aria-selected')).toBe('false')
})
it('updates an open Session menu from another window’s shortcut preferences and still renames the captured row', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-live-shortcuts'))
const other = await page.context().newPage()
const key = 'dsh.keybindings.v1'
const original = await page.evaluate(key => localStorage.getItem(key), key)
try {
await other.goto(scaffold.authenticatedUrl)
await other.getByRole('button', { name: 'Search sessions', exact: true }).waitFor()
const background = page.locator('[data-row-key="session:context-menu-other"]')
await background.click({ button: 'right' })
const item = page.getByRole('menuitem', { name: 'Rename', exact: true })
await item.waitFor()
const popup = await page.getByRole('menu').elementHandle()
const initial = await item.getAttribute('aria-keyshortcuts')
await other.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: {
'web:macos': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } },
'web:windows': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } },
'web:linux': { 'session.rename': { code: 'Comma', modifiers: ['primary', 'shift'] } },
} }))
}, key)
await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(platform === 'MacIntel' ? 'Shift+Meta+,' : 'Control+Shift+,')
expect(await item.getAttribute('aria-keyshortcuts')).not.toBe(initial)
expect(await popup!.evaluate(element => element.isConnected)).toBe(true)
expect(await background.getAttribute('aria-selected')).toBe('false')
await item.click()
const rename = page.getByRole('dialog', { name: 'Rename session', exact: true })
expect(await rename.getByLabel('Session name').inputValue()).toBe('Menu background')
await rename.getByRole('button', { name: 'Cancel', exact: true }).click()
expect(await background.getAttribute('aria-selected')).toBe('false')
await background.click({ button: 'right' })
await item.waitFor()
await other.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({ schemaVersion: 2, profiles: {
'web:macos': { 'session.rename': null }, 'web:windows': { 'session.rename': null }, 'web:linux': { 'session.rename': null },
} }))
}, key)
await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBeNull()
await other.evaluate(({ key, original }) => {
if (original === null) localStorage.removeItem(key)
else localStorage.setItem(key, original)
}, { key, original })
await expect.poll(() => item.getAttribute('aria-keyshortcuts')).toBe(initial)
await page.keyboard.press('Escape')
} finally {
const cancel = page.getByRole('dialog', { name: 'Rename session', exact: true }).getByRole('button', { name: 'Cancel', exact: true })
if (await cancel.isVisible()) await cancel.click()
await page.keyboard.press('Escape')
await other.evaluate(({ key, original }) => {
if (original === null) localStorage.removeItem(key)
else localStorage.setItem(key, original)
}, { key, original })
await other.close()
}
})
it('shares Workspace menus and keeps folder gestures independent from expansion', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-workspace'))
const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Menu workspace' })
const expanded = await workspace.getAttribute('aria-expanded')
await workspace.click({ button: 'right' })
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor()
const names = await menuLabels(menu)
expect(names).toEqual(['Rename', 'Show in folder', 'Delete workspace'])
expect(await menu.locator('svg[data-icon]').count()).toBe(3)
expect(await workspace.getAttribute('aria-expanded')).toBe(expanded)
await page.keyboard.press('Escape')
await workspace.getByRole('button', { name: /actions/i }).click()
await menu.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor()
expect(await menuLabels(menu)).toEqual(names)
await menu.getByRole('menuitem', { name: 'Rename', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await workspace.click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Delete workspace', exact: true }).click()
await page.getByRole('dialog', { name: 'Delete workspace', exact: true }).waitFor()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await page.locator('[data-sidebar-right-expand]').click()
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
await page.locator('[data-files-state="tree"]').waitFor()
const folder = page.locator('[data-files-entry="directory"]').filter({ hasText: 'folder' }).getByRole('button').first()
await folder.click({ button: 'right' })
await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).waitFor()
expect(await menuLabels(menu)).toEqual(['Show in folder', 'Copy folder path'])
expect(await folder.getAttribute('aria-expanded')).toBe('false')
await menu.getByRole('menuitem', { name: 'Copy folder path', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'folder'))
await page.getByText('Copied', { exact: true }).waitFor()
await expect(names.join('\n') + '\n').toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/workspace.txt'))
})
it('prepares complete menus, anchors above the frame inset, and treats repeated gestures as separate occurrences', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-gestures'))
const file = page.getByRole('button', { name: 'First file', exact: true })
await file.focus()
await page.keyboard.press('Shift+F10')
await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor()
await page.keyboard.press('End')
expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0)
expect(await page.getByRole('menuitem', { name: 'Download', exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('ArrowUp')
expect(await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(join(scaffold.workspaceCwd, 'first.txt'))
expect(await file.evaluate(element => element === document.activeElement)).toBe(true)
await page.evaluate(() => {
document.documentElement.style.setProperty('--dsh-frame-overlay-top', '100px')
const frames: number[] = []
const observer = new MutationObserver(() => {
for (const menu of document.querySelectorAll('[role="menu"]')) frames.push(menu.querySelectorAll('[role="menuitem"]').length)
})
observer.observe(document.body, { subtree: true, childList: true })
Object.assign(window, { menuObservation: { frames, observer } })
})
const header = page.locator('[data-session-title]').first()
const target = await header.count() > 0 ? header : page.getByText('Menu foreground', { exact: true }).last()
const box = (await target.boundingBox())!
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
await page.mouse.click(point.x, point.y, { button: 'right' })
await page.getByRole('menuitem', { name: /Copy.*[Ii][Dd]/ }).waitFor()
expect((await page.getByRole('menu').boundingBox())!.y).toBeLessThan(100)
await page.mouse.click(point.x + 1, point.y, { button: 'right' })
expect(await page.getByRole('menu').count()).toBe(0)
await file.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy file path', exact: true }).waitFor()
const previous = await page.getByRole('menu').elementHandle()
await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).waitFor()
expect(await previous!.evaluate(element => element.isConnected)).toBe(false)
await page.keyboard.press('Escape')
const counts = await page.evaluate(() => {
const observed = (window as Window & { menuObservation?: { frames: number[]; observer: MutationObserver } }).menuObservation
if (observed === undefined) throw new Error('Menu observer not installed')
observed.observer.disconnect()
document.documentElement.style.removeProperty('--dsh-frame-overlay-top')
return observed.frames
})
expect(counts.length).toBeGreaterThan(0)
expect(counts.every(count => count > 0)).toBe(true)
})
it('preserves file line navigation, URL text, image format, and existing tab close actions', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-content'))
const file = page.getByRole('button', { name: 'First file', exact: true })
await file.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Download', exact: true }).waitFor()
expect(await page.getByRole('menuitem', { name: 'Save as…', exact: true }).count()).toBe(0)
const fileDownloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: 'Download', exact: true }).click()
const fileDownload = await fileDownloadPromise
expect(fileDownload.suggestedFilename()).toBe('first.txt')
expect(await readFile(await fileDownload.path(), 'utf8')).toBe('FIRST\nSECOND\nTHIRD\n')
const started = page.getByRole('alert').filter({ hasText: 'Download started' })
await started.waitFor()
expect(await started.getByRole('button', { name: /Show in/ }).count()).toBe(0)
await file.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open in sidebar', exact: true }).click()
await page.locator('[data-document-preview]').getByText('SECOND', { exact: true }).waitFor()
await page.getByRole('link', { name: 'Website', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy link address', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('https://example.com/path?q=1#fragment')
await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Download', exact: true }).waitFor()
await expect((await menuLabels(page.getByRole('menu'))).join('\n') + '\n')
.toMatchFileSnapshot(join(import.meta.dirname, 'expected/context-menus/image.txt'))
expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).count()).toBe(1)
const downloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: 'Download', exact: true }).click()
const download = await downloadPromise
expect(download.suggestedFilename().endsWith('.gif')).toBe(true)
await page.getByRole('alert').filter({ hasText: 'Download started' }).waitFor()
const path = await download.path()
expect(path).not.toBeNull()
if (path === null) throw new Error('Browser download unavailable')
expect(await readFile(path)).toEqual(GIF)
await page.getByRole('img', { name: 'Original', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy image', exact: true }).click()
await page.getByText('Copied', { exact: true }).waitFor()
expect(await page.evaluate(async () => (await navigator.clipboard.read()).some(item => item.types.includes('image/png')))).toBe(true)
await page.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' })
expect(await page.getByRole('menuitem', { name: 'Show in folder', exact: true }).isDisabled()).toBe(true)
const remoteDownloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: 'Download', exact: true }).click()
const remoteDownload = await remoteDownloadPromise
expect(await readFile((await remoteDownload.path()))).toEqual(GIF)
await page.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Copy image', exact: true }).click()
await page.getByText('Copied', { exact: true }).waitFor()
expect(await page.evaluate(async () => (await navigator.clipboard.read()).some(item => item.types.includes('image/png')))).toBe(true)
await page.getByRole('button', { name: 'Second file', exact: true }).click()
await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).waitFor()
const firstTab = page.locator('[role="tab"]').filter({ hasText: 'first.txt' }).first()
await firstTab.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Close', exact: true }).waitFor()
expect(await page.getByRole('menuitem', { name: 'Close', exact: true }).locator('svg[data-icon]').count()).toBe(1)
await page.getByRole('menuitem', { name: 'Close', exact: true }).click()
expect(await firstTab.count()).toBe(0)
expect(await page.locator('[data-document-preview]').getByText('SECOND FILE', { exact: true }).count()).toBe(1)
expect(tripwire.pageErrors).toEqual([])
})
it('keeps original preview open when dismissing its menu and fits both themes at the viewport edge', async () => {
onTestFailed(() => saveFailureShot(page, 'context-menus-preview'))
await page.getByRole('button', { name: 'View full image: Original', exact: true }).click()
const preview = page.getByRole('dialog')
await preview.waitFor()
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
await preview.getByRole('img').evaluate(element => element.dispatchEvent(new MouseEvent('contextmenu', { clientX: 1270, clientY: 735, bubbles: true })))
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: 'Copy image', exact: true }).waitFor()
expect(await menu.getByRole('menuitem').evaluateAll(rows => rows.every(row => row.querySelector('svg[data-icon]')))).toBe(true)
const box = await menu.boundingBox()
expect(box).not.toBeNull()
expect(box!.x).toBeGreaterThan(1000)
expect(box!.y).toBeGreaterThan(500)
expect(box!.x + box!.width).toBeLessThanOrEqual(1291)
expect(box!.y + box!.height).toBeLessThanOrEqual(768)
await saveFailureShot(page, `context-menus-${colorScheme}`)
await page.keyboard.press('Escape')
expect(await menu.count()).toBe(0)
expect(await preview.count()).toBe(1)
}
await page.keyboard.press('Escape')
expect(await preview.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
})
it('offers completed Desktop destinations as toast text actions without enabling remote-image menu reveal', async () => {
const native = await browser.newPage({ viewport: { width: 1291, height: 768 }, locale: 'en-US' })
const opened = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath').mockResolvedValue({ opened: true })
try {
const destinations = { download: join(scaffold.workspaceCwd, 'downloaded.gif'), saveAs: join(scaffold.workspaceCwd, 'saved.txt') }
await native.addInitScript(({ platform, destinations }) => {
Object.defineProperty(navigator, 'platform', { value: platform })
Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1, fileActions: {
save: async ({ mode }: { mode: 'download' | 'saveAs' }) => ({ status: 'saved', path: destinations[mode] }),
} } })
}, { platform, destinations })
await native.route('https://images.example/original.gif', route => route.fulfill({ body: GIF, contentType: 'image/gif' }))
await native.goto(scaffold.authenticatedUrl)
await native.evaluate((platform) => {
document.documentElement.dataset.platform = platform === 'MacIntel' ? 'darwin' : platform === 'Win32' ? 'win32' : 'linux'
}, platform)
const group = native.locator('[data-row-key^="workspace:"]').filter({ hasText: 'Ungrouped' })
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await native.locator('[data-row-key="session:context-menu-first"]').click()
await native.getByText('CONTEXT_MENU_READY', { exact: true }).waitFor()
for (const [mode, label, text, theme] of [
['download', 'Download', 'Download complete', 'light'],
['saveAs', 'Save as…', 'Saved', 'dark'],
] as const) {
await native.emulateMedia({ colorScheme: theme })
const trigger = mode === 'download'
? native.getByRole('img', { name: 'Remote', exact: true })
: native.getByRole('button', { name: 'First file', exact: true })
await trigger.click({ button: 'right' })
await native.getByRole('menuitem', { name: label, exact: true }).click()
const notice = native.getByRole('alert').filter({ hasText: text })
const action = notice.getByRole('button', { name: platform === 'MacIntel' ? 'Show in Finder' : 'Show in folder', exact: true })
await action.click()
await notice.waitFor({ state: 'hidden' })
await expect.poll(() => opened.mock.calls.at(-1)?.[0]).toEqual({ path: destinations[mode], action: 'reveal' })
}
expect(opened).toHaveBeenCalledTimes(2)
await native.getByRole('img', { name: 'Remote', exact: true }).click({ button: 'right' })
const reveal = native.getByRole('menuitem', { name: 'Show in folder', exact: true })
await reveal.waitFor()
expect(await reveal.isDisabled()).toBe(true)
} finally {
await native.close()
opened.mockRestore()
}
})
})
+6 -8
View File
@@ -63,9 +63,9 @@ it.skipIf(process.platform !== 'darwin').each([
const dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle')
await dialog.getByRole('button', { name: '安装', exact: true }).click()
const ready = page.getByRole('dialog', { name: '插件已随 DSH 提供', exact: true })
const ready = page.getByRole('dialog', { name: '已安装', exact: true })
await ready.waitFor()
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(1)
expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0)
return ready.getByRole('button', { name: '立即启用', exact: true })
}
const enableNow = await offerCua()
@@ -199,8 +199,7 @@ it.skipIf(process.platform !== 'darwin').each([
const program = 'globalThis.counter = (globalThis.counter ?? 0) + 1'
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(1)
await toggle.click()
await toggle.waitFor({ state: 'hidden' })
await card.waitFor({ state: 'hidden' })
await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
expect(scaffold.ctx.get('computerUse')).toBeUndefined()
expect(runtimeFiber() === persistentRuntimeFiber).toBe(true)
expect(scaffold.ctx.agentPresets.defaultId).toBe(initialDefault)
@@ -211,7 +210,7 @@ it.skipIf(process.platform !== 'darwin').each([
}
expect((await runtime.run(runtime.resolve({ program, bindings: [], stateOwner: owner }))).value).toBe(2)
expect(await readFile(settingsPath, 'utf8')).toBe(saved)
await (await offerCua()).click()
await toggle.click()
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
await setup.waitFor()
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
@@ -249,9 +248,8 @@ it.skipIf(process.platform !== 'darwin').each([
expect.objectContaining({ id: 'agent-preset-registry', config: { default: 'standard', selectedDefault: 'ptc' } }),
]))
await toggle.click()
await toggle.waitFor({ state: 'hidden' })
await card.waitFor({ state: 'hidden' })
await (await offerCua()).click()
await toggle.and(page.locator('[aria-checked="false"]')).waitFor({ state: 'visible' })
await toggle.click()
await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' })
await setup.waitFor()
expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1)
+3 -3
View File
@@ -29,7 +29,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
const helper = await installNativeFixture(root)
resources.closeFixture = await startFixtureHost(helper)
const state = join(root, 'permissions.json'), audit = join(root, 'helper-events.jsonl')
const launchApplication = { name: 'Visual Studio Code.app', path: '/Applications/Visual Studio Code.app',
const launchApplication = { name: 'DSH Computer Use.app', path: '/Applications/DSH Computer Use.app',
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC' }
await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication }))
await writeFile(join(root, 'observe-helper'), '')
@@ -89,10 +89,10 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1)
await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0)
expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
expect(await accessibility.getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1)
expect(await accessibility.textContent()).toContain('In System Settings, allow')
expect(await accessibility.textContent()).toContain('to read interface controls, click, and type.')
expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1)
expect(await panel.getByRole('group', { name: 'Screen Recording', exact: true }).getByText('DSH Computer Use.app', { exact: true }).count()).toBe(1)
await expect.poll(() => accessibility.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
await expect.poll(() => panel.getAttribute('aria-busy')).toBe('false')
await compareOrRefreshGolden(expected, await captureStableAria(page, '[data-cua-settings]', scaffold.workspaceCwd), webSnapshotMode())
+1 -1
View File
@@ -563,7 +563,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
for (const palette of headingPalettes) {
expect(palette.headingFills, `${palette.platform}, dark=${String(palette.dark)}`)
.toEqual(['rgba(0, 0, 0, 0)'])
expect(palette.menuFill).toBe(palette.dark ? 'rgba(67, 69, 74, 0.45)' : 'rgba(248, 249, 250, 0.58)')
expect(palette.menuFill).toBe(palette.dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)')
expect(palette.searchBorder).toBe('rgba(0, 0, 0, 0)')
expect(palette.positions).toEqual(['sticky'])
expect(palette.headingRadii).toEqual([palette.platform === 'darwin' ? '0px' : palette.optionRadius])
@@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await blankColumn.locator('[data-sidebar-right-toggle]').click()
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
await page.setViewportSize({ width: 767, height: blankViewport.height })
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
await expect.poll(() => columns(page)).toEqual([0, 767, 0])
await page.locator('[data-sidebar-right-expand]').click()
// The fullscreen shell keeps its viewport box while its docked content closes.
await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1)
@@ -425,7 +425,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await expect.poll(() => columns(page)).toEqual([420, viewport.width - 420 - normalWidth, normalWidth])
await page.setViewportSize({ width: 767, height: viewport.height })
await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, width: 767, height: viewport.height })
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
await expect.poll(() => columns(page)).toEqual([0, 767, 0])
expect(await sidebarSnapshot(page)).toMatchObject({ mode: 'fullscreen', resizeHandleWidth: 0, coversViewport: true })
await checkpoint('A automatic fullscreen at 767px')
await column.locator('[data-sidebar-right-mode="push"]').click()
+1 -1
View File
@@ -714,7 +714,7 @@ else process.exit(1);
const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
expect(initialFilesClose).toBe(1)
await filesTab.click({ button: 'right' })
expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1)
await page.locator('[data-context-menu][role="menu"]').waitFor()
await page.keyboard.press('Escape')
await addTab.waitFor({ state: 'visible' })
const initialAdd = await addTab.count()
@@ -2,8 +2,10 @@
- menuitem "Pin session"
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- separator
- menuitem "Copy Session ID"
- separator
- menuitem "Export session"
- menuitem "Copy session ID"
- separator
- menuitem "Archive session"
@@ -86,7 +86,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -96,7 +96,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -106,7 +106,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
},
@@ -116,8 +116,8 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": true,
"time": false,
"pinned": false,
"actions": true
},
{
@@ -136,7 +136,7 @@
"Fixture badge",
"Fixture section"
],
"time": true,
"time": false,
"pinned": false,
"actions": true
}
@@ -0,0 +1,3 @@
Copy image
Download
Show in folder
@@ -0,0 +1,6 @@
Pin session
Rename
Fork session
Copy Session ID
Show in folder
Archive session
@@ -0,0 +1,3 @@
Rename
Show in folder
Delete workspace
@@ -10,11 +10,11 @@
- button "Use persistent mode"
- group "Accessibility":
- strong: Accessibility
- paragraph: In System Settings, allow Visual Studio Code.app to read interface controls, click, and type.
- paragraph: In System Settings, allow DSH Computer Use.app to read interface controls, click, and type.
- button "Request permission"
- group "Screen Recording":
- strong: Screen Recording
- paragraph: In System Settings, allow Visual Studio Code.app to capture application windows.
- paragraph: In System Settings, allow DSH Computer Use.app to capture application windows.
- button "Request permission"
- paragraph: Manage permissions in System Settings for the application named by macOS.
- region "Manage applications computer use can access":
@@ -0,0 +1 @@
- 'tooltip "Current branch: feature/a-long-current-branch-name-for-mobile-review"'
@@ -1,5 +1,6 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "提交评审"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
@@ -7,16 +8,64 @@
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "收起 panel.ts" [expanded]
- 'button "复制路径: src/panel.ts"': src/panel.ts
- text: +2 −2
- button "panel.ts 的更多操作"
- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5
- button "6 行未变更"
- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- 'button "复制路径: docs/panel.md"': docs/panel.md
- text: 新文件 +2 −0
- button "panel.md 的更多操作"
- text: "1 + # Panel 2 + Shows the change requests of a Session."
- region "全部 2 个文件":
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- button "选择第 L1 行": "1"
- text: export const n1 = 1
- button "选择第 R1 行": "1"
- text: export const n1 = 1
- button "选择第 L2 行": "2"
- text: export const n2 = 2
- button "选择第 R2 行": "2"
- text: export const n2 = 'two'
- button "选择第 L3 行": "3"
- text: export const n3 = 3
- button "选择第 R3 行": "3"
- text: export const n3 = 3
- button "选择第 L4 行": "4"
- text: export const n4 = 4
- button "选择第 R4 行": "4"
- text: export const n4 = 4
- button "选择第 L5 行": "5"
- text: export const n5 = 5
- button "选择第 R5 行": "5"
- text: export const n5 = 5
- button "6 行未变更"
- button "选择第 L12 行": "12"
- text: export const n12 = 12
- button "选择第 R12 行": "12"
- text: export const n12 = 12
- button "选择第 L13 行": "13"
- text: export const n13 = 13
- button "选择第 R13 行": "13"
- text: export const n13 = 13
- button "选择第 L14 行": "14"
- text: export const n14 = 14
- button "选择第 R14 行": "14"
- text: export const n14 = 14
- button "选择第 L15 行": "15"
- text: export const n15 = 15
- button "选择第 R15 行": "15"
- text: export const n15 = 'fifteen'
- button "选择第 L16 行": "16"
- text: export const n16 = 16
- button "选择第 R16 行": "16"
- text: export const n16 = 16
- button "选择第 L17 行": "17"
- text: export const n17 = 17
- button "选择第 R17 行": "17"
- text: export const n17 = 17
- button "选择第 L18 行": "18"
- text: export const n18 = 18
- button "选择第 R18 行": "18"
- text: export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- button "选择第 R1 行": "1"
- text: "+ # Panel"
- button "选择第 R2 行": "2"
- text: + Shows the change requests of a Session.
@@ -0,0 +1,77 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "提交评审"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
- button "刷新变更"
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "将文件树移到右侧" [expanded]
- region "全部 2 个文件":
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- button "选择第 L1 行": "1"
- text: export const n1 = 1
- button "选择第 R1 行": "1"
- text: export const n1 = 1
- button "选择第 L2 行": "2"
- text: export const n2 = 2
- button "选择第 R2 行": "2"
- text: export const n2 = 'two'
- button "选择第 L3 行": "3"
- text: export const n3 = 3
- button "选择第 R3 行": "3"
- text: export const n3 = 3
- button "选择第 L4 行": "4"
- text: export const n4 = 4
- button "选择第 R4 行": "4"
- text: export const n4 = 4
- button "选择第 L5 行": "5"
- text: export const n5 = 5
- button "选择第 R5 行": "5"
- text: export const n5 = 5
- button "6 行未变更"
- button "选择第 L12 行": "12"
- text: export const n12 = 12
- button "选择第 R12 行": "12"
- text: export const n12 = 12
- button "选择第 L13 行": "13"
- text: export const n13 = 13
- button "选择第 R13 行": "13"
- text: export const n13 = 13
- button "选择第 L14 行": "14"
- text: export const n14 = 14
- button "选择第 R14 行": "14"
- text: export const n14 = 14
- button "选择第 L15 行": "15"
- text: export const n15 = 15
- button "选择第 R15 行": "15"
- text: export const n15 = 'fifteen'
- button "选择第 L16 行": "16"
- text: export const n16 = 16
- button "选择第 R16 行": "16"
- text: export const n16 = 16
- button "选择第 L17 行": "17"
- text: export const n17 = 17
- button "选择第 R17 行": "17"
- text: export const n17 = 17
- button "选择第 L18 行": "18"
- text: export const n18 = 18
- button "选择第 R18 行": "18"
- text: export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- button "选择第 R1 行": "1"
- text: "+ # Panel"
- button "选择第 R2 行": "2"
- text: + Shows the change requests of a Session.
- separator "调整文件树宽度"
- navigation "全部 2 个文件":
- textbox "筛选文件…": src
- button "src" [expanded]
- button "src/panel.ts": panel.ts +2 −2
@@ -0,0 +1,11 @@
- text: 与 origin/main 比较,包含未提交的修改
- button "创建变更请求"
- button "分支"
- button "全部 0 个文件" [disabled]
- button "刷新变更"
- button "全部展开" [disabled]
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- status:
- text: 无法完整读取 Git 过滤器配置,尚未执行比较。请检查 Git 配置和读取上限后重试
- button "重试"
@@ -0,0 +1,4 @@
- button "本地变更"
- text: HEAD → origin/main
- button "查看本会话的变更:1 个文件,新增 1 行,删除 0 行": +1 −0 1 个文件
- button "创建变更请求"
@@ -0,0 +1,4 @@
- button "Local changes"
- text: HEAD → origin/main
- 'button "View the changes of this Session: 60 files, 60 lines added, 0 lines deleted"': +60 −0 60 files
- button "Create change request"
@@ -12,6 +12,6 @@
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- button "检查 已加载 1 项检查,仍有更多"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -7,6 +7,6 @@
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- button "检查 已加载 1 项检查,仍有更多"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -0,0 +1,7 @@
- region "评论 R2–4":
- text: 评论 R2–4
- textbox "留下评论…": 请核对边界条件
- button "取消"
- button "添加到输入框"
- button "开始评审"
- button "GitHub 评论选项"
@@ -0,0 +1 @@
- button "代码审查和变更 查看代码变更与本会话的变更请求"
@@ -0,0 +1,25 @@
> ## User Comment 2
> File: src/extra.ts
> Side: R
> Lines: 1
> Source: Local (branch)
> Diff hunk:
> ```diff
> @@ -0,0 +1,1 @@
> +export const extra = 1
> ```
> Comment:
> 请检查这项修改
> ## User Comment 1
> File: src/app.ts
> Side: R
> Lines: 1
> Source: Local (branch)
> Diff hunk:
> ```diff
> @@ -0,0 +1,1 @@
> +export const app = 1
> ```
> Comment:
> 请解释这行代码
@@ -0,0 +1,6 @@
- textbox "发消息或创建任务, / 调用指令, @ 文件或对话":
- paragraph:
- button "移除引用"
- text: "## User Comment 2 File: src/extra.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const extra = 1 ``` Comment: 请检查这项修改"
- button "移除引用"
- text: "## User Comment 1 File: src/app.ts Side: R Lines: 1 Source: Local (branch) Diff hunk: ```diff @@ -0,0 +1,1 @@ +export const app = 1 ``` Comment: 请解释这行代码"
@@ -0,0 +1,4 @@
- region "评论 R2–4":
- text: 你的评论 R2–4 已发布到 GitHub 请核对边界条件
- button "添加到输入框"
- button "在 GitHub 查看"
@@ -0,0 +1,14 @@
- dialog "提交评审":
- heading "提交评审" [level=2]
- button "关闭"
- text: 待提交评论(1)
- textbox "评审总结…"
- status: 请求更改时,请填写评审总结
- radio "评论"
- text: 评论
- radio "批准"
- text: 批准
- radio "请求更改" [checked]
- text: 请求更改
- button "取消"
- button "提交评审" [disabled]
@@ -0,0 +1,14 @@
- dialog "提交评审":
- heading "提交评审" [level=2]
- button "关闭"
- text: 待提交评论(1)
- textbox "评审总结…": 需要补充边界检查
- status: 请求更改时,请填写评审总结
- radio "评论"
- text: 评论
- radio "批准"
- text: 批准
- radio "请求更改" [checked]
- text: 请求更改
- button "取消"
- button "提交评审"
@@ -0,0 +1,3 @@
- button "当前分支:main": main
- checkbox "新建工作树"
- text: 新建工作树
@@ -4,6 +4,6 @@ grown: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":
darwin: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800}
windows: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":40,"height":400,"bottom":440},"sidebar":{"top":40,"height":400,"bottom":440},"rightbar":{"top":40,"height":400,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":40,"height":400,"bottom":440}],"pageHeight":800}
browser: {"bottom":{"top":440,"height":360,"bottom":800},"main":{"top":0,"height":440,"bottom":440},"sidebar":{"top":0,"height":440,"bottom":440},"rightbar":{"top":0,"height":440,"bottom":440},"overlay":{"top":0,"height":800,"bottom":800},"handles":[{"top":0,"height":440,"bottom":440}],"pageHeight":800}
narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":240,"bottom":240},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
narrow: {"bottom":{"top":240,"height":360,"bottom":600},"main":{"top":0,"height":240,"bottom":240},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":240,"bottom":240},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
unregistered: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
collapsed: {"bottom":{"top":600,"height":0,"bottom":600},"main":{"top":0,"height":600,"bottom":600},"sidebar":{"top":0,"height":600,"bottom":600},"rightbar":{"top":0,"height":600,"bottom":600},"overlay":{"top":0,"height":600,"bottom":600},"handles":[],"pageHeight":600}
@@ -0,0 +1,21 @@
- paragraph:
- button "Relative colon:24–30"
- text: ·
- button "Absolute colon:30"
- text: ·
- button "Fragment:12"
- text: ·
- button "Column:24:5"
- text: ·
- button "Zero line"
- text: ·
- button "Reversed range"
- text: ·
- link "Bare URL":
- /url: index.ts:24
- text: · Blocked script ·
- link "Development server":
- /url: localhost:3000
- text: ·
- link "Mail":
- /url: mailto:10086
Loaded 100 of 1469 files, more files were not shown because too many files have changed in this diff. Show more