Merge remote-tracking branch 'origin/master' into fix/update-copy-review

# Conflicts:
#	packages/client/ui-workspace/src/client/locales.ts
This commit is contained in:
liyao committed 2026-09-24 20:35:12 +08:00
commit 5b4cfa5141
485 files changed
+6400 -4443

No files matched your search

@@ -9,11 +9,11 @@
en: 4945ce54f66df55c
zh: 9fbd833c1d8122a7
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/decision:
en: 75b5f9e642b6c25c
zh: c775a113d5ea0626
en: 2388519a1afaac92
zh: 6bf758f0560e29a5
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/incremental-session-log-field:
en: edae015c24a2bc42
zh: 0c7e23be17d9d424
en: 4e3138fbdd94259b
zh: 8fda801522045b88
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/plugin-package-field:
en: 0e51b8d8a1bd60fc
zh: dd18fd176c3f101f
@@ -33,5 +33,5 @@
en: b6eff63f49220dcc
zh: 8e4a6c785ed2256a
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/consequences:
en: c9a0a5323480eb3d
zh: 5df6e88de50038ce
en: 144fc01ad3b2b9bd
zh: 0acff935bd7835de
@@ -14,13 +14,13 @@ Both values belong only on the official DeepSeek adapter path. Adding them to `G
## Decision
`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. A merged body that fails to serialize is sent without extension fields or acceptance under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call.
## Incremental session-log field
`@deepseek-ai/dsh-session-log-deepseek` owns the default-on `dsh_session_log` field. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
`@deepseek-ai/dsh-session-log-deepseek` owns the default-on `dsh_session_log` field. When enabled, each request carrying a live Session id sends the longest contiguous canonical event run after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity that fits the `maxBytes` limit of the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
The failure direction is at least once. A transport or provider rejection records no watermark. A crash after remote acceptance but before the watermark persists causes replay after resume, never a skipped sequence. Existing session checkpoints persist the event; the upload plugin owns no second store.
@@ -85,7 +85,7 @@ About 98% of the measured v1 real-session events were `assistant/chunk`. Omittin
## Consequences
Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata.
Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries newly unaccepted Session events, up to `maxBytes` per request. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata. A merged body that fails to serialize is the exception: the request proceeds without extension fields and logs a warning.
The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available.
@@ -14,13 +14,13 @@ Status: implemented
## 决策
`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。合并后的正文无法序列化时,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)不带扩展字段发送,也不执行接受。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
## 增量会话日志字段
`@deepseek-ai/dsh-session-log-deepseek` 拥有默认开启的 `dsh_session_log` 字段。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
`@deepseek-ai/dsh-session-log-deepseek` 拥有默认开启的 `dsh_session_log` 字段。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后、能放进[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)中 `maxBytes` 上限的最长连续权威事件段。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
失败方向为至少一次。传输失败或提供方拒绝不会记录水位。远端接受后、水位持久化前发生崩溃,会在恢复后触发重放,绝不会跳过序列。现有会话检查点会持久化该事件;上传插件不拥有第二份存储。
@@ -85,7 +85,7 @@ Status: implemented
## 后果
DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。合并后的正文无法序列化是例外:请求不带扩展字段继续发送,并记录一条告警。
`delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。
@@ -15,14 +15,14 @@
en: 59c3648960630ff8
zh: 1891f8659e660e45
/agent-note-workspace-file-service/decision/the-workspacefiles-file-operations:
en: cb489857d97a1b11
zh: b1a7880df5607c05
en: efdecd9cf80c03e7
zh: 5ea40e48ab0559a2
/agent-note-workspace-file-service/decision/paths-on-the-wire:
en: 7b5a96adc08177bc
zh: ae4692e5e62e7de0
/agent-note-workspace-file-service/decision/file-checks-and-workspace-containment:
en: 4399dcc29336f886
zh: 69cf2fc174dc83a2
en: 1b588f346590a0fd
zh: 1cdac02b967b157f
/agent-note-workspace-file-service/decision/failures:
en: 0e419dd7bdcf38ba
zh: 6cb588a77593913c
@@ -45,8 +45,8 @@
en: 246bb7aed62f6c9b
zh: debf6d4129ee477b
/agent-note-workspace-file-service/testing:
en: 3ab0dabf8ef85258
zh: c3d0a634bb5e2928
en: 776c380b6150065e
zh: df3f0f1050966ff7
/agent-note-workspace-file-service/deferred:
en: b033218338ca0122
zh: aa48fa67f9eb36c3
@@ -41,7 +41,7 @@ Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gat
- **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream.
- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding.
- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics.
- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered.
- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`. A child the listing reports as a directory opens through its resolved target, including a Windows junction, and the listing then names that target; [directory link listing](../bug-fix/2026-09-18-windows-directory-junction-listing.md) owns the rule. Dotfiles are listed; nothing is filtered.
- **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation.
### Paths on the wire
@@ -54,12 +54,12 @@ Two path vocabularies leave the service, and each method uses exactly one. `read
`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods. `list` instead follows a final link and judges the resolved target: the target must stat as a directory, and a link to a file or a link whose target is gone is `not-directory` (kind `symlink`). An empty path is a `gateway/bad-request`.
2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position.
After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position; a final link resolves and passes containment before its target kind is read.
### Failures
@@ -140,7 +140,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
## Testing
Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, directory links, links whose target is gone, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
## Deferred
@@ -41,7 +41,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
- **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。
- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。
- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。
- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。
- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`。列表报告为目录的子项经其解析目标打开,Windows 目录联接亦然,此时列表以该目标命名;[目录链接列举](../bug-fix/2026-09-18-windows-directory-junction-listing.zh.md)拥有该规则。dotfile 照常列出,不做任何过滤。
- **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
### 线路上的路径
@@ -54,12 +54,12 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`)。`list` 改为跟随末端链接并判定解析后的目标:目标经 stat 必须为目录,指向文件的链接或目标已消失的链接为 `not-directory`(kind 为 `symlink`)。空路径是 `gateway/bad-request`。
2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。
路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。
### 失败
@@ -140,7 +140,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
## Testing
`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、目录链接、目标已消失的链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
## Deferred
@@ -15,5 +15,5 @@
en: 4cc4b61d76f84ff3
zh: bdd29ea66037f744
/agent-note-default-deepseek-session-log-upload/consequences:
en: 1302d84a3d2b168b
zh: aec67b793539f898
en: 31d0c8deb33dbe3f
zh: 8af3905630c629a0
@@ -23,6 +23,6 @@ This supersedes only the opt-in default in the [request-extension decision](2026
## Consequences
Eligible requests send the complete unaccepted canonical log suffix, including message text, tool arguments and results, workspace paths, and feedback, to the resolved DeepSeek endpoint or configured gateway. No prompt tokens or model-visible content are added. Request bodies can grow substantially, and provider rejection still fails the request. OTel remains independent; disabling OTel does not disable this contribution.
Eligible requests send the unaccepted canonical log suffix, up to `maxBytes` per request under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md), including message text, tool arguments and results, workspace paths, and feedback, to the resolved DeepSeek endpoint or configured gateway. No prompt tokens or model-visible content are added. Request bodies grow by up to that limit, and provider rejection still fails the request. OTel remains independent; disabling OTel does not disable this contribution.
Configuration tests cover default-on and explicit overrides with and without test environment markers. Both DeepSeek protocol Loader cases observe the default request field and recorded acceptance watermark, and explicit-off cases observe their absence. Existing headless, ACP, Web, and SDK recordings validate their declared upload policies without rewriting committed Session generations.
@@ -23,6 +23,6 @@ Status: implemented
## 后果
符合条件的请求会向解析后的 DeepSeek 端点或已配置网关发送完整的未接受规范日志后缀,包括消息文本、工具参数与结果、工作区路径和反馈。不增加提示词 token 或模型可见内容。请求正文可能显著增大,提供方拒绝仍会使请求失败。OTel 保持独立,关闭 OTel 不会关闭此贡献。
符合条件的请求会向解析后的 DeepSeek 端点或已配置网关发送未接受的规范日志后缀,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)每次最多 `maxBytes`,包括消息文本、工具参数与结果、工作区路径和反馈。不增加提示词 token 或模型可见内容。请求正文最多增加该上限,提供方拒绝仍会使请求失败。OTel 保持独立,关闭 OTel 不会关闭此贡献。
配置测试覆盖有无测试环境标记时的默认开启与显式覆盖。两种 DeepSeek 协议的 Loader 用例观察默认请求字段和已记录的接受水位,显式关闭用例观察两者均不存在。现有 headless、ACP、Web 和 SDK 录制验证各自声明的上传策略,无需重写已提交的会话代际。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-24-bounded-session-log-upload.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-09-24-bounded-session-log-upload.md
/agent-note-bounded-deepseek-session-log-upload:
en: d35839d24f5704a7
zh: 8bc7e460fd3bd936
/agent-note-bounded-deepseek-session-log-upload/problem:
en: b6b54d7ee8810f7e
zh: b0fdfa6c2c3cc224
/agent-note-bounded-deepseek-session-log-upload/decision:
en: d2fe09351a82f7d6
zh: ad0741f63e14a8ad
/agent-note-bounded-deepseek-session-log-upload/alternatives-considered:
en: 88985b4e1f5c7a4f
zh: 32617f7ff75ee825
/agent-note-bounded-deepseek-session-log-upload/consequences:
en: e19fb6136ffd2250
zh: 01dacd7a44c3f837
@@ -0,0 +1,30 @@
# Agent Note: Bounded DeepSeek Session-log upload
Status: implemented
English | [中文](2026-09-24-bounded-session-log-upload.zh.md)
## Problem
Without a watermark of the current Session format generation, a request carries the whole canonical log: the first request after upload becomes enabled, the first request after a format migration, and the first request of a fork all do. A long log can exceed the V8 string limit of 2^29 − 24 UTF-16 code units on 64-bit hosts. The adapter's merged `JSON.stringify` then throws `RangeError`, the adapter reports `TRANSPORT`, the default retry policy repeats the same serialization, and no request reaches the 2xx that would advance the watermark, so every model request in that Session fails. Below the limit, one request can still carry hundreds of MiB: on Node v24.17.0 and macOS arm64, a 269 MiB first upload of a 96,341-event Session waited 34 s for its 2xx.
## Decision
`session-log-deepseek.Config.maxBytes`, 8 MiB by default, bounds the complete serialized `dsh_session_log` field in UTF-8 bytes. Each request carries the longest contiguous run after the watermark whose field, including the header and numeric envelope fields, fits the limit. Acceptance advances the watermark to that run's `throughSeq`, and later requests continue from there. When the first pending event alone exceeds the limit, the request omits the field, the plugin logs a warning, and the watermark stays before that event until `maxBytes` admits it. An event whose JSON text exceeds the runtime's string limit counts as exceeding every limit, so it blocks upload without failing the request, and the plugin serializes it once per request.
The DeepSeek adapter treats a merged request body that fails to serialize as an extension failure rather than a transport failure. It sends the base body without any extension field, skips the joint acceptance transaction, and reports the omitted field names through the provider plugin's logger, so contributors resend unaccepted state on a later request. A base body that cannot serialize still fails the request.
This supersedes complete-suffix delivery and fail-closed serialization in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md). That note still owns field ownership, cancellation, acceptance, and the remaining fail-closed failures.
## Alternatives considered
**Serialize the whole pending run first and bound it only on overflow.** Measuring bytes requires serializing every candidate event either way. In a synthetic benchmark, a 14 KiB step took 14.3 µs whole versus 13.8 µs per event, while a 135 MiB backlog took 182 ms to serialize before any fallback versus 10 ms to fill 8 MiB. The whole-run attempt also still overflows the string limit for the logs that motivated this change.
**Send an oversized first event alone.** Exceeding the configured limit reintroduces the provider body-size rejections that still fail the model request. Omission keeps every request bounded, and the warning names the blocked event.
**Omit only the field that fails to serialize.** Acceptance is one joint transaction across fields, so per-field omission would split the registry's acceptance contract. Dropping every extension field on this path only leaves the small `dsh_plugin_packages` inventory out of one request.
## Consequences
A backlog drains at up to 8 MiB per accepted request by default. At the end-to-end rate measured for the 269 MiB upload, about 8 MiB/s, an 8 MiB field adds about 1 s to each request while a backlog drains, and a 0.86 GB backlog drains in about 100 requests. In the same 96,341-event Session, the median per-step append is 11 KiB and the largest event is 414 KiB, so ordinary steps stay far below the limit and only an unusual event or a small configured limit reaches the oversized-event path. While a backlog drains, the endpoint receives a `throughSeq` behind the Session's current tip. Preparation failures, acceptance failures, and provider rejections still fail the model request.
Plugin tests cover the exact byte boundary, UTF-8 counting, backlog draining across accepted requests, the oversized-event warning with its unchanged watermark, an event past the string limit, and the 8 MiB default through plugin configuration. Adapter tests cover the base-body fallback through the provider plugin's logger and through an adapter without a reporter.
@@ -0,0 +1,30 @@
# Agent Note: 有上限的 DeepSeek 会话日志上传
Status: implemented
[English](2026-09-24-bounded-session-log-upload.md) | 中文
## 问题
没有当前会话格式代的水位时,请求会携带整份规范日志:开启上传后的第一次请求、格式迁移后的第一次请求,以及 fork 的第一次请求都是如此。长会话日志可能超过 64 位宿主上 V8 的字符串上限,即 2^29 − 24 个 UTF-16 码元。此时适配器合并正文的 `JSON.stringify` 会抛出 `RangeError`,适配器报告 `TRANSPORT`,默认重试策略重复同样的序列化,任何请求都无法拿到推进水位所需的 2xx,于是该会话的每次模型请求都会失败。即使低于上限,单次请求仍可能携带数百 MiB:在 Node v24.17.0 与 macOS arm64 上,一个 96,341 条事件的会话首次上传 269 MiB,等待 34 秒才收到 2xx。
## 决策
`session-log-deepseek.Config.maxBytes` 默认 8 MiB,按 UTF-8 字节限制完整的序列化 `dsh_session_log` 字段。每次请求携带水位之后字段能放进上限的最长连续事件段,字段大小包括 header 与数值型 envelope 字段。接受操作把水位推进到该段的 `throughSeq`,后续请求从那里继续。第一条待发事件本身就超过上限时,请求省略该字段,插件记录一条告警,水位停在该事件之前,直到 `maxBytes` 能容纳它。JSON 文本超过运行时字符串上限的事件视为超过任何上限,因此它会阻塞上传但不会使请求失败,插件每次请求只序列化它一次。
DeepSeek 适配器把合并后请求正文的序列化失败视为扩展失败,而非传输失败。它发送不含任何扩展字段的基础正文,跳过联合接受事务,并通过提供方插件的 logger 报告被省略的字段名,因此贡献方会在之后的请求中重发未接受的状态。基础正文本身无法序列化时,请求仍然失败。
本决策取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的完整后缀交付与 fail-closed 序列化。该记录仍负责字段所有权、取消、接受以及其余 fail-closed 失败。
## 考虑过的替代方案
**先序列化整个待发事件段,只在超限时再按上限截取。** 无论哪种方式,量字节都要序列化每个候选事件。合成基准中,14 KiB 的一步整体序列化耗时 14.3 µs,逐条累加耗时 13.8 µs;135 MiB 的积压在回退前整体序列化就要 182 ms,逐条装满 8 MiB 只需 10 ms。对促成本改动的日志,整体尝试仍会超过字符串上限。
**单独发送超限的第一条事件。** 超过配置上限会重新引入提供方请求体大小拒绝,而这类拒绝仍会使模型请求失败。省略字段能让每次请求都有上限,告警也会指出被阻塞的事件。
**只省略序列化失败的那个字段。** 接受是跨字段的一个联合事务,按字段省略会拆分注册表的接受契约。在这条路径上丢弃全部扩展字段,只会让体积很小的 `dsh_plugin_packages` 清单缺席一次请求。
## 后果
默认情况下,积压按每次已接受请求最多 8 MiB 补传。按那次 269 MiB 上传实测的端到端速度(约 8 MiB/s),补传期间 8 MiB 的字段让每次请求多约 1 秒,0.86 GB 的积压约 100 次请求补完。在同一个 96,341 条事件的会话中,每步追加量的中位数是 11 KiB,最大单条事件是 414 KiB,因此普通步骤远低于上限,只有异常事件或很小的配置上限才会走到超限事件路径。积压补传期间,端点收到的 `throughSeq` 会落后于会话当前末尾。准备失败、接受失败与提供方拒绝仍会使模型请求失败。
插件测试覆盖精确字节边界、UTF-8 计数、跨已接受请求的积压补传、超限事件告警及其不变的水位、超过字符串上限的事件,以及经插件配置生效的 8 MiB 默认值。适配器测试覆盖经提供方插件 logger 报告的基础正文回退,以及没有 reporter 的适配器上的同一回退。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-18-windows-directory-junction-listing.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-09-18-windows-directory-junction-listing.md
/agent-note-list-directory-links-in-the-workspace-file-tree:
en: 70fbeeae3fa4a6e6
zh: 6ed08f09c9d1d02e
/agent-note-list-directory-links-in-the-workspace-file-tree/problem:
en: eec5c2e8edd0e0ff
zh: 03c0f3490bb891c0
/agent-note-list-directory-links-in-the-workspace-file-tree/decision:
en: ad36ceb185d15d69
zh: 71847eaa4641a9d7
/agent-note-list-directory-links-in-the-workspace-file-tree/alternatives-considered:
en: 8a9daf7bd2d78b65
zh: 69f46ee1108c2747
/agent-note-list-directory-links-in-the-workspace-file-tree/consequences:
en: 3cbc15d04a639292
zh: 4a9c94a1a1e8aeca
@@ -0,0 +1,27 @@
# Agent Note: List directory links in the workspace file tree
Status: implemented
English | [中文](2026-09-18-windows-directory-junction-listing.zh.md)
## Problem
Windows profile directories such as `Documents\My Music` are directory junctions: reparse points that `lstat` reports as symlinks rather than directories. The `list` endpoint gated on `lstat` before resolving, so it refused every final link with `workspace-file/not-directory`, while its own listing reported those same children as `directory` because `listDir` resolves each child. The file tree therefore listed the junctions and refused to open them, and the Sidebar reported `My Music`, `My Pictures`, and `My Videos` as not a directory.
## Decision
`list` follows a final link and judges what it resolves to. The resolved target must pass workspace containment and must stat as a directory, so a directory link inside the workspace lists through its target, and the returned workspace path names that target. A link resolving outside the workspace is `workspace-file/outside-workspace`; a link to a non-directory, or one whose target is gone, is `workspace-file/not-directory` with kind `symlink`.
`read`, `readBytes`, `readAll`, `readRelated`, and `stat` keep their `lstat` gate that refuses a final symlink, so no read path follows a link. The [Workspace Files service](../architecture/2026-09-05-workspace-files-service.md) keeps ownership of the service; its earlier rule that `list` rejects a final symlink is superseded here.
## Alternatives considered
**Drop the `lstat` gate for every method.** Rejected: reading through a link is a separate exposure decision, and the file methods' no-follow rule is a trust rule the report asked to keep.
**Special-case Windows junctions.** Rejected: `fs-local` already reports a junction as a `symlink` path entry, and the resolve-then-contain rule is identical for a junction and a POSIX directory symlink.
**Keep the gate and report a linked directory from the parent listing only.** Rejected: the listing already reports the resolved type, so the endpoint contradicted itself and a reader could not open what the parent presented as a directory.
## Consequences
A directory link inside the workspace now lists like its target, and the Windows profile junctions expand in the Sidebar. A link resolving outside the workspace reports `outside-workspace` instead of `not-directory`, which names the actual reason. Read access is unchanged. Tests cover a directory link inside the workspace, a link resolving outside it, and a link whose target is deleted; the spec creates a junction with `symlink(target, path, 'junction')` on Windows and a directory symlink elsewhere, so one spec runs on both.
@@ -0,0 +1,27 @@
# Agent Note: 列举工作区文件树中的目录链接
Status: implemented
[English](2026-09-18-windows-directory-junction-listing.md) | 中文
## Problem
Windows 的个人目录如 `Documents\My Music` 是目录联接:`lstat` 把这类重解析点报告为符号链接而不是目录。`list` 端点在解析之前先用 `lstat` 把关,于是把所有末端链接都以 `workspace-file/not-directory` 拒绝;而它自己的列举又把同样的子项报告为 `directory`,因为 `listDir` 会解析每个子项。文件树因此既列出这些联接、又拒绝打开它们,侧栏把 `My Music`、`My Pictures`、`My Videos` 报告为非目录。
## Decision
`list` 跟随末端链接并判定其解析结果。解析后的目标必须通过工作区包含检查,且经 stat 必须为目录,因此工作区内的目录链接经其目标列举,返回的工作区路径也以该目标命名。解析到工作区外的链接为 `workspace-file/outside-workspace`;指向非目录的链接、或目标已消失的链接,为 kind 为 `symlink` 的 `workspace-file/not-directory`。
`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 保留拒绝末端符号链接的 `lstat` 把关,因此没有任何读取路径跟随链接。[工作区文件服务](../architecture/2026-09-05-workspace-files-service.zh.md)继续拥有该服务;它此前「`list` 拒绝末端符号链接」的规则由本记录取代。
## Alternatives considered
**对所有方法去掉 `lstat` 把关。** 被否:经链接读取是另一项暴露决策,文件方法的「不跟随」规则是报告方要求保留的信任规则。
**单独处理 Windows 目录联接。** 被否:`fs-local` 已把目录联接报告为 `symlink` 路径项,且「先解析再判包含」的规则对目录联接与 POSIX 目录符号链接完全相同。
**保留把关,只靠父层列举报告被链接的目录。** 被否:列举本来就报告解析后的类型,端点因此自相矛盾,读取方无法打开父层呈现为目录的条目。
## Consequences
工作区内的目录链接现在与其目标一样可列举,Windows 个人目录中的联接可在侧栏展开。解析到工作区外的链接报告 `outside-workspace` 而不是 `not-directory`,如实说明原因。读取权限不变。测试覆盖工作区内的目录链接、解析到工作区外的链接,以及目标被删除的链接;spec 在 Windows 上用 `symlink(target, path, 'junction')` 创建目录联接,在其他平台创建目录符号链接,因此同一 spec 两端都跑。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-24-exited-holder-lock-takeover.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-09-24-exited-holder-lock-takeover.md
/agent-note-taking-over-a-writer-lock-whose-holder-exited:
en: ecf6fb35c9eed3ca
zh: 59c29b64e23608e4
/agent-note-taking-over-a-writer-lock-whose-holder-exited/problem:
en: cf143bc56530fe17
zh: a4e3ee8fa3b8c465
/agent-note-taking-over-a-writer-lock-whose-holder-exited/decision:
en: af22bae72ff97ef8
zh: 428c09871bba95d1
/agent-note-taking-over-a-writer-lock-whose-holder-exited/alternatives-considered:
en: ee800ed269cfc4a2
zh: df983b552ef25273
/agent-note-taking-over-a-writer-lock-whose-holder-exited/testing:
en: 7eb7c5818049415a
zh: f84c39c55bb3bc5a
/agent-note-taking-over-a-writer-lock-whose-holder-exited/consequences:
en: 03f4cda9290188e5
zh: 07f800776c061218
@@ -0,0 +1,51 @@
# Agent Note: Taking over a writer lock whose holder exited
Status: implemented
English | [中文](2026-09-24-exited-holder-lock-takeover.zh.md)
## Problem
`withFileLock` in [dsh-atomic-write](../../../../packages/util/atomic-write/README.md) creates `<file>.lock` with exclusive create and removes it in a `finally`. A process that ends without running that `finally` leaves the lock behind, and every later writer of the file times out until someone deletes it by hand. The [original decision](../../archived/architecture/2026-07-30-settings-write-path-integrity.md) accepted this because file age cannot distinguish a crashed holder from a paused one.
The profile package lock (`<profile>/package.json.lock`) made the cost visible. `dsh plugin` installs no signal handler, so Ctrl-C, SIGTERM, or closing its terminal ends the process while pnpm runs, and the lock stays. `dsh` and `dsh web` exit right after application disposal resolves, while a cancelled installation is still restoring files and has not released the lock. A crash, SIGKILL, or out-of-memory kill has the same effect. After any of these, every plugin operation on that profile fails after `lockWaitMs` (two minutes by default) without saying that the holder is gone.
## Decision
- The lock keeps the `<pid>\n` record earlier releases wrote, so locks left before this change are taken over and processes of both releases interoperate.
- A contender that finds a lock reads its record. When a signal probe of the PID fails with `ESRCH`, the holder is proven gone and the lock is taken over. `EPERM` means the process exists under another user and the lock is kept. A record naming the contender's own process is kept too: that process is running, and the browser Worker's process shim reports its own PID as absent.
- Contenders that read the same record serialize on a claim file, `<file>.lock.takeover-<first 16 hex digits of the record's SHA-256>`, created with `wx`. The claimant re-reads the lock and probes its PID again, removes it only if it still holds that record and the PID is still absent, removes the claim, and retries acquisition at once. The record's holder cannot release it once its PID is absent, another takeover needs the same claim, and a new holder that reused the PID fails the second probe, so a claimant never removes a lock that another contender acquired after the exited holder's.
- A record that is empty, incomplete, not a decimal PID, or names PID 0 or a PID beyond the int32 range the probe accepts is waited for. None of these proves that the holder stopped.
- Takeover proves only that the recorded process exited, not that the processes it started stopped writing. The [Plugin Manager](../../../../packages/boot/plugin-manager/README.md) records each pnpm run in `.plugin-manager/run.json` while the run lasts; an operation that finds that record waits up to five seconds for the recorded run to stop and otherwise refuses to run, naming the process and the record.
- The probe runs on the contender's host. Writers on several hosts sharing one `DSH_HOME` over a network filesystem are unsupported; the `flock` that guards Session files does not reliably exclude them either.
## Alternatives considered
**Remove a lock older than a fixed age.** Age cannot separate a crashed holder from one running a ten-minute pnpm installation, and the plugin manager legitimately holds the lock that long.
**Kernel-released locks.** The kernel releases them when the holder dies, which removes the problem instead of detecting it. The [Session write lease](../../../../packages/session/session-persistence-jsonl/src/lease.ts) already holds one on both platforms: `flock` through `@deepseek-ai/node-addon-system` on POSIX and a named semaphore through `koffi` on Windows. Moving `withFileLock` onto it would give the zero-dependency `dsh-atomic-write` both dependencies, and processes of earlier releases still exclude each other only through `<file>.lock`, so a transition would have to hold both locks. This stays the stronger fix if PID reuse or shared-filesystem deployments turn out to matter.
**Rename the stale lock aside and restore it when the renamed record differs.** Restoring can race a third contender that acquires the empty path in between, which leaves two holders. The claim file prevents that race without restoring anything.
**Record the hostname and a nonce.** A hostname would keep a writer on another host from probing a PID that is not its own, but no supported deployment shares these files across hosts, and macOS changes its hostname when networks change, which would leave locks from before the change in place. A nonce would make each record unique, but the second probe under the claim already rejects a holder that reused the PID.
**Terminate the recorded pnpm run instead of refusing.** Its PID can have been reused by an unrelated process, which a signal would then kill. Refusing leaves that case to the operator, whom the diagnostic names the process and the record.
**Signal handlers in `dsh plugin` only.** This covers one of the paths that leave a lock and leaves crashes, forced exits, and the other lock files behind.
## Testing
| Evidence | Behaviour |
|---|---|
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | Takeover of an exited holder's record, through an injected probe everywhere and a really exited process on POSIX; eight contenders over one exited holder never overlap; live, own-process, other-user, empty, incomplete, non-PID, process-group, and out-of-range records are waited for; a holder that reused the PID after the claim, an unreadable lock, a claim held by another contender, and a record replaced after the claim are left in place; a lock that cannot be removed is waited for; an `EPERM` claim refusal is retried, another claim failure surfaces, and a claim that cannot be removed does not fail the operation. |
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI, service, and repair runs are recorded while they run and the record is removed afterwards; an active recorded run refuses the operation before pnpm starts; a stopped one's record is removed; a record that names no run refuses the operation. |
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | A real recorded run that is still writing finishes before the next operation's pnpm starts. |
## Consequences
- Locks left by an exited process, including those written before this change, no longer need an operator; the next writer proceeds without waiting for its deadline.
- A PID that a live process reused after a reboot keeps the lock in place until an operator removes it, as before.
- Processes that share the lock file but not a PID namespace, such as containers over one volume or hosts over a network filesystem, can see a live holder as exited. Such deployments are not supported.
- A contender that crashes while it holds a claim leaves that claim, and the named lock is then not taken over automatically.
- A pnpm run left by an exited operation blocks later operations until it stops or an operator removes its record.
- A holder that is still alive keeps its lock, so this does not bound how long a live operation holds it.
@@ -0,0 +1,51 @@
# Agent Note: 接管持有者已退出的写锁
Status: implemented
[English](2026-09-24-exited-holder-lock-takeover.md) | 中文
## 问题
[dsh-atomic-write](../../../../packages/util/atomic-write/README.zh.md) 的 `withFileLock` 以独占创建方式创建 `<file>.lock`,并在 `finally` 中移除它。进程若未执行该 `finally` 就结束,锁会留在原地,该文件后续的每个写入方都会超时,直到有人手动删除它。[原始决策](../../archived/architecture/2026-07-30-settings-write-path-integrity.md)接受了这一点,因为文件存续时间无法区分已崩溃的持有者与暂停的持有者。
profile 包锁(`<profile>/package.json.lock`)让这个代价显现出来。`dsh plugin` 不安装信号处理器,因此在 pnpm 运行期间按 Ctrl-C、发送 SIGTERM 或关闭其终端都会结束进程,锁随之留下。`dsh` 与 `dsh web` 在应用销毁完成后立即退出,而此时被取消的安装仍在恢复文件,尚未释放锁。崩溃、SIGKILL 或内存不足被杀也有同样效果。发生上述任一情况后,该 profile 上的每个插件操作都会在 `lockWaitMs`(默认两分钟)后失败,且不说明持有者已不存在。
## 决策
- 锁沿用早期版本写入的 `<pid>\n` 记录,因此本变更之前遗留的锁也会被接管,新旧版本的进程可以互操作。
- 竞争者发现锁时读取其记录。若对该 PID 的信号探测以 `ESRCH` 失败,则证明持有者已不存在,锁会被接管。`EPERM` 表示该进程以其他用户身份存在,锁保留。指向竞争者自身进程的记录同样保留:该进程正在运行,而浏览器 Worker 的进程模拟层会把自身 PID 报告为不存在。
- 读到同一条记录的竞争者通过一个以 `wx` 创建的认领文件 `<file>.lock.takeover-<记录 SHA-256 的前 16 位十六进制>` 串行化。认领者重新读取锁并再次探测其 PID,仅当锁仍是该记录且该 PID 仍不存在时才移除它,随后移除认领文件并立即重试获取。PID 不存在时记录的持有者无法再释放它,另一次接管需要同一个认领文件,复用了该 PID 的新持有者无法通过第二次探测,因此认领者绝不会移除另一个竞争者在已退出持有者之后获得的锁。
- 记录为空、不完整、不是十进制 PID,或指向 PID 0 或超出探测所接受的 int32 范围的 PID 时,竞争者继续等待。这些情况都无法证明持有者已经停止。
- 接管只能证明记录中的进程已退出,不能证明它启动的进程已停止写入。[Plugin Manager](../../../../packages/boot/plugin-manager/README.zh.md) 在每次 pnpm 运行期间把它记录在 `.plugin-manager/run.json` 中;发现该记录的操作最多等待五秒让记录中的运行停止,否则拒绝运行,并指明该进程与记录文件。
- 探测在竞争者所在主机上进行。不支持多台主机通过网络文件系统共享同一个 `DSH_HOME` 写入;保护 Session 文件的 `flock` 同样无法可靠地排除它们。
## 考虑过的替代方案
**移除超过固定时长的锁。** 时长无法区分已崩溃的持有者与正在运行十分钟 pnpm 安装的持有者,而插件管理器会合理地持锁这么久。
**由内核释放的锁。** 持有者死亡时内核会释放它们,从根源上消除问题而不是检测它。[Session 写租约](../../../../packages/session/session-persistence-jsonl/src/lease.ts)已在两个平台上持有这种锁:POSIX 上通过 `@deepseek-ai/node-addon-system` 使用 `flock`,Windows 上通过 `koffi` 使用命名信号量。把 `withFileLock` 迁移到它上面,会让零依赖的 `dsh-atomic-write` 引入这两个依赖;而早期版本的进程之间只通过 `<file>.lock` 互斥,因此过渡期必须同时持有两种锁。若 PID 复用或共享文件系统部署变得重要,这仍是更强的修复。
**把陈旧锁重命名到别处,若重命名后的记录不同再恢复。** 恢复可能与在此间隙获取空路径的第三个竞争者竞态,导致出现两个持有者。认领文件无需任何恢复即可防止这一竞态。
**记录主机名和随机数。** 主机名能让其他主机上的写入方不去探测不属于自己的 PID,但没有受支持的部署会跨主机共享这些文件,而且 macOS 切换网络时会改变主机名,导致变更前留下的锁无法接管。随机数能让每条记录唯一,但认领后的第二次探测已经能排除复用了该 PID 的持有者。
**终止记录中的 pnpm 运行而不是拒绝。** 其 PID 可能已被无关进程复用,发送信号会杀死该进程。拒绝则把这种情况交给操作者,诊断会指明该进程与记录文件。
**只在 `dsh plugin` 中安装信号处理器。** 这只覆盖留下锁的其中一条路径,崩溃、强制退出和其他锁文件仍会遗留。
## 验证
| 证据 | 行为 |
|---|---|
| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | 接管已退出持有者的记录,各平台通过注入的探测验证,POSIX 上另用真实已退出的进程验证;八个竞争者同时接管一个已退出持有者时从不重叠;存活、自身进程、其他用户、空、不完整、非 PID、进程组和超出范围的记录均继续等待;认领后复用了该 PID 的持有者、无法读取的锁、被另一竞争者持有的认领、认领后被替换的记录都保留原样;无法移除的锁会继续等待;以 `EPERM` 拒绝的认领会重试,其他认领失败会抛出,认领无法移除时操作不失败。 |
| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI、service 与修复运行在运行期间被记录,结束后删除记录;仍活动的记录中运行会在 pnpm 启动前拒绝操作;已停止运行的记录被删除;不指向任何运行的记录会拒绝操作。 |
| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 仍在写入的真实记录中运行会在下一个操作的 pnpm 启动前结束。 |
## 后果
- 已退出进程留下的锁(包括本变更之前留下的)不再需要操作者处理;下一个写入方无需等到期限即可继续。
- 重启后被存活进程复用的 PID 会让锁保持原样,直到操作者移除,与之前相同。
- 共享锁文件但不共享 PID 命名空间的进程,例如共享同一卷的容器或通过网络文件系统共享的多台主机,可能把存活持有者视为已退出。不支持此类部署。
- 竞争者在持有认领文件时崩溃会留下该认领文件,对应的锁随后不会被自动接管。
- 已退出操作留下的 pnpm 运行会阻止后续操作,直到它停止或操作者删除其记录。
- 仍存活的持有者会保留其锁,因此本变更不限制存活操作的持锁时长。
@@ -30,8 +30,8 @@
en: 7bc0fab85c7c91d0
zh: 3eafcde6b1e3249c
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/the-bash-consumer:
en: af561a23104227cd
zh: ac6fd59518d1bb55
en: f7f359c89a0e8fd6
zh: 07d0d551a35716e0
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/escalation-one-approved-wider-retry-after-a-denial:
en: 035640e276225e97
zh: 09fd95921529369b
@@ -78,7 +78,7 @@ Provider-owned I/O requires asynchronous confinement. `confine(argv, policy, sig
`dsh-bash-sandbox` extends `LocalBashExecutor`, awaits `ctx.sandbox` for the exact `['bash', '-c', command]` argv with the execution signal, rechecks cancellation, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`; a present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start reject with `SANDBOX_UNAVAILABLE` before handle publication, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under <mode> mode]`) and forbids retrying around a denial, and the `sandbox:policy` context tells the model to try an available tool normally; a rejected escalation's error text says the action stays denied; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
#### Escalation: one approved wider retry after a denial
@@ -78,7 +78,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,携带执行信号等待 `ctx.sandbox` 为精确的 `['bash', '-c', command]` argv 完成限制准备,重新检查取消后直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会在发布句柄前以 `SANDBOX_UNAVAILABLE` 拒绝;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under <mode> mode]`)并禁止绕过拒绝重试,`sandbox:policy` 上下文要求模型正常尝试可用工具;被拒绝的升级请求的错误文本说明该操作仍被拒绝。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
#### 升级机制:拒绝后一次经批准的更宽重试
+9 -1
View File
@@ -1,6 +1,6 @@
---
name: agent-experience
description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
metadata:
date: "2026-09-11"
---
@@ -11,3 +11,11 @@ metadata:
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
## Tool definitions
- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
+1 -1
View File
@@ -35,7 +35,7 @@ export async function runDumpConfigSchema(
// Trusted module diagnostics must not precede the JSON document on stdout.
process.stdout.write = process.stderr.write.bind(process.stderr)
try {
dump = await generateConfigSchema(NAME, loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
dump = await generateConfigSchema(loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
} finally {
process.stdout.write = stdoutWrite
}
+2
View File
@@ -23,6 +23,7 @@ import {
installFailLoud,
loadOverlayPatches,
loadProfile,
reportSkippedBundles,
PluginPackages,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
@@ -166,6 +167,7 @@ export function initializeProfileFromDefault(
export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
reportSkippedBundles(NAME, profile)
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
return profile
}
+1 -1
View File
@@ -28,7 +28,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
}
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({ skippedBundles: [] }); export const reportSkippedBundles = () => {}; export const loadLayeredEnv = () => ({})')
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
import { writeFileSync } from 'node:fs';
@@ -108,7 +108,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu)
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
expect(capabilities.runtime.version).toBe('0.1.0')
expect(capabilities.runtime.version).toBe('0.1.1')
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
const pdf = await readFile(join(root, 'result.pdf'))
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
+2 -2
View File
@@ -60,7 +60,7 @@ describe('runProfile with an application-owned profile', () => {
throw failure
})
if (stage === 'composition') vi.mocked(createRuntimeResolution).mockRejectedValueOnce(failure)
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
patches: [], layers: [],
}
@@ -121,7 +121,7 @@ describe('runProfile with an application-owned profile', () => {
writeFileSync(profilePatch, '- id: target\n config: { profile: true, priority: profile }\n')
writeFileSync(overlay, '- id: target\n config: { overlay: true, priority: overlay }\n')
writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: profilePatch,
patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
layers: [{
+1 -1
View File
@@ -136,7 +136,7 @@ async function bootWeb(
await mkdir(dirname(link), { recursive: true })
await symlink(packageDir, link, 'junction')
}
let profile: Profile = {
let profile: Profile = { skippedBundles: [],
name: 'spec',
dir: profileDir,
layers: [],
+1 -1
View File
@@ -10,7 +10,7 @@
"lib/index.js"
],
"dependencies": {
"@deepseek-ai/libreoffice-kit": "^0.1.0",
"@deepseek-ai/libreoffice-kit": "^0.1.1",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
+2 -1
View File
@@ -2,7 +2,7 @@
import { delimiter, join } from 'node:path'
import { inspect } from 'node:util'
import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -21,6 +21,7 @@ async function main(): Promise<void> {
installOfficeEngineResolution(runtimeDir)
const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
const profile = loadProfileDirectory('dsh', projectDir, installAnchor)
reportSkippedBundles('dsh', profile)
const application = runProfile({
environment: loadLayeredEnv('dsh'),
profile: 'desktop',
+2 -2
View File
@@ -6,8 +6,8 @@
en: e1c3082481d0cace
zh: 28cc4c5e63c0219c
/apps-web-browser-e2e/completion-observations:
en: 47f5168d5f93be53
zh: 79fc78415e76950c
en: 1a93c8d1a0e07ad8
zh: e7c252f54f19c137
/apps-web-browser-e2e/these-are-host-face-tests:
en: 8c406d423c8e2137
zh: a66a144fd146f56c
+1 -1
View File
@@ -16,7 +16,7 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m
## Completion observations
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
+1 -1
View File
@@ -16,7 +16,7 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
## 完成状态观察
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
+18 -4
View File
@@ -372,10 +372,24 @@ describe('web e2e: clickable links gallery', () => {
// — TOOL_VARIANTS has no entry for it, so it falls to the generic row with
// no openable path even though its create still joins the produced chips.
expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
// The collapsed web-fetch row's summary is its URL, opened in a new tab.
const fetchSummaryLink = page.locator(`a[class*="fileLink"][href="${FETCH_URL}"]`)
expect(await fetchSummaryLink.count()).toBe(1)
expect(await fetchSummaryLink.getAttribute('target')).toBe('_blank')
// A row-center click can hit the nested fileLink button and invoke the
// Host opener; right-edge clicks expand the card itself.
await expandTurnProcesses(page)
// Clicking the collapsed summary link opens the page without expanding the row.
const fetchPopupPromise = page.waitForEvent('popup')
await fetchSummaryLink.click()
const fetchPopup = await fetchPopupPromise
try {
await fetchPopup.waitForURL(FETCH_URL)
} finally {
await fetchPopup.close()
}
expect(await page.locator('[data-web="fetch"]').count()).toBe(0)
// A row-center click can hit a nested summary link and invoke its opener;
// right-edge clicks expand the card itself.
for (const row of [
/^Search clickable link styles/,
/^Fetch /,
@@ -400,7 +414,7 @@ describe('web e2e: clickable links gallery', () => {
const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`)
await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1)
expect(await page.locator('a[href^="ftp:"]').count()).toBe(0)
expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1)
expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(2)
expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
@@ -423,7 +437,7 @@ describe('web e2e: clickable links gallery', () => {
['markdown anchor', guideLink],
['file mention', mentions.first()],
['search source', sourceLink.first()],
['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
['fetch url', page.locator(`[data-web="fetch"] a[href="${FETCH_URL}"]`)],
] as const) {
expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
+4 -2
View File
@@ -197,14 +197,16 @@ describe.skipIf(MODE === 'record').each([
await target.getByText('Acme Swift', { exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(1)
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.model).toBe('acme-swift')
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().model, { timeout: 10_000 })
.toBe('acme-swift')
await trigger.click()
await page.getByRole('menuitem', { name: /推理等级/ }).click()
await page.getByRole('menuitemradio', { name: 'Max', exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(2)
expect(scaffold.ctx.agentDefaultModel.currentSelection()?.reasoningEffort).toBe('max')
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort, { timeout: 10_000 })
.toBe('max')
await page.route('**/api/session/selectModel', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
+20 -1
View File
@@ -107,7 +107,24 @@ describe('web e2e: the composer model switch is the default for later sessions',
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
await trigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const blocked = scaffold.ctx.hmr.runExclusive(async () => {
entered.resolve(undefined)
await release.promise
})
try {
await entered.promise
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
await expect.poll(() => trigger.textContent()).toContain('Acme Large')
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
} finally {
release.resolve(undefined)
await blocked
}
// The switch is what sets the default: the shared Agent-route settings section
// now names it, beside the provider profiles the Models page writes.
@@ -119,6 +136,8 @@ describe('web e2e: the composer model switch is the default for later sessions',
expect(document).toContain(`provider: ${ROUTE}`)
expect(document).toContain(`model: ${MODEL}`)
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection())
.toEqual({ provider: ROUTE, model: MODEL })
// A session created after the switch starts from it...
expect(await currentOf(await createSession('default-model-after')))
.toEqual({ provider: ROUTE, model: MODEL })
@@ -83,7 +83,10 @@
- text: One cursor token, one focus ring.
- listitem: Mirror spec (non-http) A non-http source renders inert.
- button "Inspect"
- button "Fetch https://docs.example.test/tokens" [expanded]
- button "Fetch https://docs.example.test/tokens" [expanded]:
- text: Fetch
- link "https://docs.example.test/tokens":
- /url: https://docs.example.test/tokens
- link "https://docs.example.test/tokens":
- /url: https://docs.example.test/tokens
- text: HTTP 200
@@ -0,0 +1,5 @@
- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话"
- button "添加文件或调用指令"
- button "访问模式,当前:工作区内修改": 工作区内修改
- button "选择模型,当前 Acme Large": Acme Large
- button "发送消息" [disabled]
@@ -25,8 +25,8 @@
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
- switch "代码工作工具" [checked]
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools" [checked]
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
- switch "Coding Tools"
- text: Keyboard shortcuts
- paragraph: Browse available shortcuts and input actions
- button "View shortcuts"
- paragraph: View and edit available shortcuts and input actions
- button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
@@ -25,8 +25,8 @@
- text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
- switch "代码工作工具"
- text: 快捷键
- paragraph: 查看当前可用的快捷键和输入操作
- button "查看快捷键"
- paragraph: 查看和编辑当前可用的快捷键和输入操作
- button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session A B": A B
- text: New Session A B
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌘ C": ⌘ C
- text: New Session ⌘ C
@@ -1,5 +1,3 @@
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + C": Ctrl + C
- text: New Session Ctrl + C
@@ -7,126 +7,72 @@
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts ⌘ /": ⌘ /
- text: Open keyboard shortcuts ⌘ /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
- text: New Session ⌥ ⌘ N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
- text: Toggle left sidebar ⌥ ⌘ B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
- text: Search sessions ⌥ ⌘ K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
- text: Add workspace ⌥ ⌘ O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
- text: Rename session ⇧ ⌘ R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
- text: Fork session ⇧ ⌘ F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
- text: Archive session ⌥ ⌘ A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings ⌘ ,": ⌘ ,
- text: Open settings ⌘ ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
- text: Toggle right sidebar ⇧ ⌘ B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
- text: Workspace files ⌥ ⌘ P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal ⌃ `"': "⌃ `"
- text: "New terminal ⌃ `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split ⌘ \\": ⌘ \
- text: Split ⌘ \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
- text: Toggle panel fullscreen ⌥ ⌘ Enter
- listitem:
- text: Refresh current page
- button "Edit shortcut for Refresh current page"
- button "Refresh current page No shortcut": No shortcut
- text: Refresh current page No shortcut
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- text: Close current page or window ⌥ ⌘ W
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- listitem: Use the complementary Queue / Steer action ⌘ Enter
- listitem: Open reference menu @
- listitem: New line ⇧ Enter
- listitem: Send message Enter
- listitem: Open command menu /
- listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- listitem: Close menu or top dialog Esc
- listitem: Move menu selection ↑ ↓
- listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- listitem: Allow once Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -4,6 +4,7 @@
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts": abc
- button "Clear search"
- status: No matching shortcuts
- contentinfo:
- button "Restore all defaults"
@@ -7,127 +7,72 @@
- region "Application":
- list:
- listitem:
- text: Open keyboard shortcuts
- button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- button "Open keyboard shortcuts Ctrl + /": Ctrl + /
- text: Open keyboard shortcuts Ctrl + /
- listitem:
- text: New Session
- button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
- text: New Session Ctrl + Alt + N
- listitem:
- text: Toggle left sidebar
- button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
- text: Toggle left sidebar Ctrl + Alt + B
- listitem:
- text: Search sessions
- button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
- text: Search sessions Ctrl + Alt + K
- listitem:
- text: Add workspace
- button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
- text: Add workspace Ctrl + Alt + O
- listitem:
- text: Rename session
- button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
- text: Rename session Ctrl + Shift + R
- listitem:
- text: Fork session
- button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
- text: Fork session Ctrl + Shift + F
- listitem:
- text: Archive session
- button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
- text: Archive session Ctrl + Alt + A
- listitem:
- text: Open settings
- button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- button "Open settings Ctrl + ,": Ctrl + ,
- text: Open settings Ctrl + ,
- listitem:
- text: Toggle right sidebar
- button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
- text: Toggle right sidebar Ctrl + Shift + B
- listitem:
- text: Workspace files
- button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
- text: Workspace files Ctrl + Alt + P
- listitem:
- text: New terminal
- button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- 'button "New terminal Ctrl + `"': "Ctrl + `"
- text: "New terminal Ctrl + `"
- listitem:
- text: Split
- button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- button "Split Ctrl + \\": Ctrl + \
- text: Split Ctrl + \
- listitem:
- text: Toggle panel fullscreen
- button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
- text: Toggle panel fullscreen Ctrl + Alt + Enter
- listitem:
- text: Refresh current page
- button "Remove shortcut for Refresh current page"
- button "Edit shortcut for Refresh current page"
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
- text: Refresh current page Ctrl + Alt + R
- listitem:
- text: Close current page or window
- button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
- listitem:
- text: Stop generating
- button "Stop generating Esc Esc" [disabled]: Esc Esc
- text: Close current page or window Ctrl + Alt + W
- region "Message input":
- heading "Message input" [level=3]
- list:
- listitem:
- text: Use the complementary Queue / Steer action
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
- listitem:
- text: Open reference menu
- button "Open reference menu @" [disabled]: "@"
- listitem:
- text: New line
- button "New line Shift + Enter" [disabled]: Shift + Enter
- listitem:
- text: Send message
- button "Send message Enter" [disabled]: Enter
- listitem:
- text: Open command menu
- button "Open command menu /" [disabled]: /
- listitem: Use the complementary Queue / Steer action Ctrl + Enter
- listitem: Open reference menu @
- listitem: New line Shift + Enter
- listitem: Send message Enter
- listitem: Open command menu /
- listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- listitem:
- text: Close menu or top dialog
- button "Close menu or top dialog Esc" [disabled]: Esc
- listitem:
- text: Move menu selection
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: Select menu item
- button "Select menu item Enter" [disabled]: Enter
- listitem: Close menu or top dialog Esc
- listitem: Move menu selection ↑ ↓
- listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- listitem:
- text: Allow once
- button "Allow once Enter" [disabled]: Enter
- listitem:
- text: Reject
- button "Reject Esc" [disabled]: Esc
- listitem: Allow once Enter
- listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- text: 0 customized
@@ -4,6 +4,7 @@
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键": abc
- button "清空搜索"
- status: 没有匹配的快捷键
- contentinfo:
- button "恢复全部默认"
@@ -7,126 +7,72 @@
- region "应用操作":
- list:
- listitem:
- text: 快捷键速查
- button "移除快捷键速查快捷键"
- button "修改快捷键速查快捷键"
- button "快捷键速查 ⌘ /": ⌘ /
- text: 快捷键速查 ⌘ /
- listitem:
- text: 新会话
- button "移除新会话快捷键"
- button "修改新会话快捷键"
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
- text: 新会话 ⌥ ⌘ N
- listitem:
- text: 展开/收起左侧栏
- button "移除展开/收起左侧栏快捷键"
- button "修改展开/收起左侧栏快捷键"
- button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
- text: 展开/收起左侧栏 ⌥ ⌘ B
- listitem:
- text: 搜索会话
- button "移除搜索会话快捷键"
- button "修改搜索会话快捷键"
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
- text: 搜索会话 ⌥ ⌘ K
- listitem:
- text: 添加工作区
- button "移除添加工作区快捷键"
- button "修改添加工作区快捷键"
- button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
- text: 添加工作区 ⌥ ⌘ O
- listitem:
- text: 重命名会话
- button "移除重命名会话快捷键"
- button "修改重命名会话快捷键"
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
- text: 重命名会话 ⇧ ⌘ R
- listitem:
- text: 分叉会话
- button "移除分叉会话快捷键"
- button "修改分叉会话快捷键"
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
- text: 分叉会话 ⇧ ⌘ F
- listitem:
- text: 归档会话
- button "移除归档会话快捷键"
- button "修改归档会话快捷键"
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
- text: 归档会话 ⌥ ⌘ A
- listitem:
- text: 打开设置
- button "移除打开设置快捷键"
- button "修改打开设置快捷键"
- button "打开设置 ⌘ ,": ⌘ ,
- text: 打开设置 ⌘ ,
- listitem:
- text: 展开/收起右侧栏
- button "移除展开/收起右侧栏快捷键"
- button "修改展开/收起右侧栏快捷键"
- button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
- text: 展开/收起右侧栏 ⇧ ⌘ B
- listitem:
- text: 工作区文件
- button "移除工作区文件快捷键"
- button "修改工作区文件快捷键"
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
- text: 工作区文件 ⌥ ⌘ P
- listitem:
- text: 新建终端
- button "移除新建终端快捷键"
- button "修改新建终端快捷键"
- 'button "新建终端 ⌃ `"': "⌃ `"
- text: "新建终端 ⌃ `"
- listitem:
- text: 分栏
- button "移除分栏快捷键"
- button "修改分栏快捷键"
- button "分栏 ⌘ \\": ⌘ \
- text: 分栏 ⌘ \
- listitem:
- text: 面板全屏/退出全屏
- button "移除面板全屏/退出全屏快捷键"
- button "修改面板全屏/退出全屏快捷键"
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
- text: 面板全屏/退出全屏 ⌥ ⌘ Enter
- listitem:
- text: 刷新当前页面
- button "修改刷新当前页面快捷键"
- button "刷新当前页面 暂无快捷键": 暂无快捷键
- text: 刷新当前页面 暂无快捷键
- listitem:
- text: 关闭当前页面/窗口
- button "移除关闭当前页面/窗口快捷键"
- button "修改关闭当前页面/窗口快捷键"
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
- listitem:
- text: 停止生成
- button "停止生成 Esc Esc" [disabled]: Esc Esc
- text: 关闭当前页面/窗口 ⌥ ⌘ W
- region "消息输入":
- heading "消息输入" [level=3]
- list:
- listitem:
- text: 使用互补的 Queue/Steer 方式
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
- listitem:
- text: 打开引用菜单
- button "打开引用菜单 @" [disabled]: "@"
- listitem:
- text: 换行
- button "换行 ⇧ Enter" [disabled]: ⇧ Enter
- listitem:
- text: 发送消息
- button "发送消息 Enter" [disabled]: Enter
- listitem:
- text: 打开命令菜单
- button "打开命令菜单 /" [disabled]: /
- listitem: 使用互补的 Queue/Steer 方式 ⌘ Enter
- listitem: 打开引用菜单 @
- listitem: 换行 ⇧ Enter
- listitem: 发送消息 Enter
- listitem: 打开命令菜单 /
- listitem: 停止生成 Esc Esc
- region "菜单与弹层":
- heading "菜单与弹层" [level=3]
- list:
- listitem:
- text: 关闭菜单或顶层弹窗
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
- listitem:
- text: 移动菜单选择
- button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
- listitem:
- text: 选择菜单项
- button "选择菜单项 Enter" [disabled]: Enter
- listitem: 关闭菜单或顶层弹窗 Esc
- listitem: 移动菜单选择 ↑ ↓
- listitem: 选择菜单项 Enter
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- listitem:
- text: 允许一次
- button "允许一次 Enter" [disabled]: Enter
- listitem:
- text: 拒绝
- button "拒绝 Esc" [disabled]: Esc
- listitem: 允许一次 Enter
- listitem: 拒绝 Esc
- contentinfo:
- button "恢复全部默认" [disabled]
- text: 0 项已自定义
@@ -1 +1 @@
Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description](<path/to/image.png>) when an image supports an explanation or comparison. Use [Description](<path/to/image.png>) when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
+1 -1
View File
@@ -734,7 +734,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
patches: [],
}
}))
const profile: Profile = {
const profile: Profile = { skippedBundles: [],
name: 'scaffold',
dir: profileDir,
layers: extraLayers,
+16 -3
View File
@@ -23,9 +23,9 @@ async function bind(page: Page, primary: string, label: string, previous?: strin
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
await remove.click()
await remove.waitFor({ state: 'hidden' })
await dialog.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
await dialog.getByRole('button', { name: 'Remove', exact: true }).click()
await dialog.getByRole('group').waitFor({ state: 'hidden' })
}
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
const recorder = dialog.getByRole('group', { name: label, exact: true })
@@ -125,6 +125,19 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
await terminalGuide.focus()
expect(await page.getByRole('tooltip').count()).toBe(0)
const terminalEntry = panel.locator('[data-sidebar-right-guide-entry="terminal"]')
const shellMenu = terminalEntry.getByRole('button', { name: 'Choose shell', exact: true })
const titleBounds = await terminalEntry.getByText('New terminal', { exact: true }).boundingBox()
const arrowBounds = await shellMenu.boundingBox()
expect(arrowBounds!.x).toBeGreaterThanOrEqual(titleBounds!.x + titleBounds!.width)
expect(arrowBounds!.x - titleBounds!.x - titleBounds!.width).toBeLessThan(8)
await shellMenu.click()
await page.getByRole('menu').waitFor()
expect(await panel.locator('[data-sidebar-terminal]').count()).toBe(0)
await page.keyboard.press('Escape')
await page.getByRole('menu').waitFor({ state: 'detached' })
expect(await shellMenu.evaluate(element => element === document.activeElement)).toBe(true)
await bind(page, primary, 'Browser', 'Toggle right sidebar')
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
await browserGuide.hover()
+3 -2
View File
@@ -61,8 +61,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click()
await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0)
await reference.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
await reference.getByRole('button', { name: 'Remove', exact: true }).click()
await reference.getByRole('group').waitFor({ state: 'hidden' })
}
await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
await page.keyboard.press('Meta+Shift+Comma')
+97 -38
View File
@@ -19,8 +19,8 @@ describe('web e2e: shortcut reference', () => {
afterAll(async () => { await browser?.close(); await scaffold?.close() })
it.each([
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' },
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' },
{ locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' },
{ locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' },
])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => {
const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash'
const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } })
@@ -46,14 +46,13 @@ describe('web e2e: shortcut reference', () => {
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 440, height: 36 })
const rows = dialog.getByRole('listitem')
const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true })
const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '消息输入' : 'Message input', exact: true })
.getByRole('listitem').last()
await stopRow.getByText(locale === 'zh-CN' ? '停止生成' : 'Stop generating', { exact: true }).waitFor()
const stopButtons = stopRow.getByRole('button')
expect(await stopButtons.count()).toBe(1)
expect(await stopButtons.isDisabled()).toBe(true)
expect(await stopButtons.count()).toBe(0)
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
.toEqual(new Set([42]))
expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42
@@ -72,29 +71,22 @@ describe('web e2e: shortcut reference', () => {
const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow)
await compareOrRefreshGolden(join(expected, `${locale}.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
const fixedButtons = dialog.getByRole('button', { disabled: true })
const fixedButton = fixedButtons.first()
expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45')
const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
await fixedButton.hover()
expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const fixedBackground = await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)
await stopRow.hover()
expect(await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar'
const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar'
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) })
const editButton = page.getByRole('button', { name: editLabel, exact: true })
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText(
locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true },
) })
const badge = boundRow.getByRole('button').last()
const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
const restingColor = await badgeColor()
await boundRow.locator(':scope > span').first().hover()
expect(await badgeColor()).toBe(restingColor)
await badge.hover()
expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)')
await boundRow.getByRole('button').first().hover()
expect(await badgeColor()).toBe(restingColor)
const badge = boundRow.getByRole('button', { name: locale === 'zh-CN' ? '修改快捷键速查快捷键' : 'Edit shortcut for Open keyboard shortcuts', exact: true })
const restingColor = await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)
await badge.hover({ position: { x: 12, y: 12 } })
expect(await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(restingColor)
expect(await boundRow.evaluate(element => getComputedStyle(element).borderRadius)).toBe('12px')
expect(await badge.boundingBox()).toEqual(await boundRow.boundingBox())
await badge.click()
await boundRow.getByRole('group').waitFor()
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -104,7 +96,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await dialog.getByRole('heading', { name: title, exact: true }).hover()
const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity)
const boundActionsOpacity = () => boundRow.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)
expect(await boundActionsOpacity()).toBe('0')
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
@@ -118,14 +110,17 @@ describe('web e2e: shortcut reference', () => {
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await boundActionsOpacity()).toBe('0')
await row.hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1)
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('1')
expect(await row.getByRole('button').count()).toBe(1)
await editButton.click()
const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true })
const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true })
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await recorder.evaluate(element => ({
color: getComputedStyle(element).color, border: getComputedStyle(element).borderColor,
})))
.toEqual({ color: 'rgb(97, 102, 107)', border: 'rgba(0, 0, 0, 0.16)' })
expect(await recorder.evaluate(element => ({
focused: element === document.activeElement,
outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow,
@@ -155,7 +150,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await page.keyboard.press('Shift+Tab')
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
@@ -168,12 +163,14 @@ describe('web e2e: shortcut reference', () => {
}))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' })
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)')
await page.getByRole('button', { name: removeLabel, exact: true }).click()
await editButton.click()
await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
await inline.waitFor({ state: 'hidden' })
const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true })
await unbound.waitFor()
expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0)
await unbound.click()
expect(await row.getByRole('button').count()).toBe(1)
await editButton.click({ position: { x: 12, y: 12 } })
await inline.waitFor()
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
@@ -182,7 +179,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
expect(await row.getByRole('button').count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
for (const query of ['abc', 'sendEnter']) {
await page.getByRole('searchbox').fill(query)
@@ -245,11 +242,13 @@ describe('web e2e: shortcut reference', () => {
await page.setViewportSize({ width: 1440, height: 1000 })
await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en')
const viewButton = page.getByRole('button', { name: view, exact: true })
const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
const viewHint = `${locale === 'zh-CN' ? '全局唤起查看' : 'Open from anywhere'} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
await boundRow.getByRole('button').first().click()
await badge.click()
await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor()
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
@@ -266,7 +265,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await viewButton.hover()
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
await page.getByRole('searchbox').fill('abc')
expect(await dialog.getByRole('listitem').count()).toBe(0)
@@ -286,7 +285,7 @@ describe('web e2e: shortcut reference', () => {
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await confirm.waitFor({ state: 'hidden' })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
expect(await dialog.locator('footer').textContent()).toBe(locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults')
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -322,6 +321,65 @@ describe('web e2e: shortcut reference', () => {
} finally { await context.close() }
})
it.each([
{ platform: 'Win32', primary: 'Control', keys: 'Ctrl + Alt + N' },
{ platform: 'MacIntel', primary: 'Meta', keys: '⌥ ⇧ ⌘ F12' },
])('shows the shortcut inside New Session on hover and keyboard focus at wide and narrow widths on $platform', async ({ platform, primary, keys }) => {
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
try {
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
const page = await context.newPage()
await page.goto(scaffold.authenticatedUrl)
const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
await newSession.waitFor()
if (platform === 'MacIntel') await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' })
await page.keyboard.press(`${primary}+/`)
await page.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
await page.keyboard.press(platform === 'Win32' ? 'Control+Alt+N' : 'Meta+Alt+Shift+F12')
await page.getByRole('group', { name: 'New Session', exact: true }).waitFor({ state: 'hidden' })
await page.keyboard.press('Escape')
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
for (const width of [420, 264]) {
const handle = (await page.locator('[data-side="sidebar"]').boundingBox())!
await page.mouse.move(handle.x + handle.width / 2, 240)
await page.mouse.down()
await page.mouse.move(width, 240, { steps: 10 })
await page.mouse.up()
await expect.poll(() => newSession.locator('kbd').allTextContents()).toEqual(keys.split(' '))
const centerOffset = () => newSession.evaluate((element) => {
const button = element.getBoundingClientRect()
const content = element.querySelector('[class*="newSessionContent"]')!.getBoundingClientRect()
return Math.abs(button.x + button.width / 2 - content.x - content.width / 2)
})
expect(await centerOffset()).toBeLessThan(1)
const hintStyle = () => newSession.locator('[class*="newSessionShortcut"]').evaluate(element => ({
opacity: getComputedStyle(element).opacity, mask: getComputedStyle(element.previousElementSibling!).maskImage,
}))
expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
await newSession.hover()
expect(await centerOffset()).toBeLessThan(1)
const mask = await newSession.locator('[class*="newSessionLabelMask"]').boundingBox()
const hint = await newSession.locator('[class*="newSessionShortcut"]').boundingBox()
expect(mask!.x + mask!.width).toBeLessThanOrEqual(hint!.x + 1)
expect((await hintStyle()).opacity).toBe('1')
expect((await hintStyle()).mask).toContain('linear-gradient')
await newSession.screenshot({ path: fileURLToPath(new URL(`../../../.artifacts/new-session-${process.pid}-${platform}-${colorScheme}-${width}.png`, import.meta.url)) })
expect(await page.getByRole('tooltip').count()).toBe(0)
await page.mouse.move(700, 100)
expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
await newSession.focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await newSession.evaluate(element => element.matches(':focus-visible'))).toBe(true)
expect((await hintStyle()).opacity).toBe('1')
expect(await page.getByRole('tooltip').count()).toBe(0)
await newSession.evaluate((element) => { element.blur() })
}
}
} finally { await context.close() }
})
it.each([
{ platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' },
{ platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' },
@@ -371,7 +429,8 @@ describe('web e2e: shortcut reference', () => {
await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await other.getByRole('button', { name: 'Settings', exact: true }).waitFor()
await other.keyboard.press(referenceKey)
await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click()
await other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
await other.getByRole('button', { name: 'Remove', exact: true }).click()
await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor()
await page.bringToFront()
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
+3 -2
View File
@@ -1176,8 +1176,9 @@ describe('web e2e: shipped right Sidebar', () => {
}
expect(await terminalEntry.evaluate(node => getComputedStyle(node).overflow)).toBe('hidden')
const terminalActions = terminalEntry.getByRole('button')
for (const action of await terminalActions.all()) {
expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe('0px')
const terminalButtons = await terminalActions.all()
for (const [index, action] of terminalButtons.entries()) {
expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe(index === 0 ? '0px' : '4px')
await action.hover()
expect(await action.evaluate(node => getComputedStyle(node).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
}
+3 -2
View File
@@ -341,9 +341,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
const cardBox = (await entry.boundingBox())!
const titleBox = (await entry.getByText('New terminal', { exact: true }).boundingBox())!
const triggerBox = (await selector.boundingBox())!
expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
expect(triggerBox.x).toBeGreaterThanOrEqual(titleBox.x + titleBox.width)
expect(triggerBox.x - titleBox.x - titleBox.width).toBeLessThan(8)
await page.emulateMedia({ colorScheme: 'dark' })
await selector.click()
await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
+61 -3
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished, vi } from 'vitest'
import type { SpeechInput, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { SpeechInput, SpeechPreparationState, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type {} from '@deepseek-ai/dsh-experimental-speech-to-text'
import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, webSnapshotMode, watchConsole,
@@ -19,7 +19,7 @@ const interruptedExpected = fileURLToPath(new URL('../../../snapshots/web/voice-
const recordingExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/recording.expected.md', import.meta.url))
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submits only the reviewed transcript through Session replay', async () => {
it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from standby and submits only the reviewed transcript through Session replay', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-voice-browser-'))
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
@@ -40,11 +40,14 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi
expect(input.audio.byteLength).toBeGreaterThan(44)
return { text: prompt.slice(prefix.length), audioSeconds: 1, inferenceSeconds: 0.1 }
})
let preparation: SpeechPreparationState = { phase: 'unprepared' }
const readinessListeners = new Set<() => void>()
await scaffold.ctx.plugin({ inject: ['speechToText'], apply(ctx) {
ctx.effect(() => ctx.speechToText.register({
info: { id: 'sensevoice-local' as SpeechProviderId, name: 'Recorded recognizer', location: 'host-local', languages: ['auto'] },
preparation: {
snapshot: () => ({ phase: 'standby' }), subscribe: () => () => {},
snapshot: () => preparation,
subscribe: (listener) => { readinessListeners.add(listener); return () => { readinessListeners.delete(listener) } },
prepare: () => { throw new Error('Cached resources must allow recording without preparation') },
cancel: async () => {},
},
@@ -68,6 +71,61 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true })
const setupMic = page.getByRole('button', { name: 'Open voice input setup', exact: true })
await input.fill(prefix)
await setupMic.click()
const setup = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true })
await setup.waitFor()
const setupAction = setup.getByRole('button', { name: 'Go to setup', exact: true })
await expect.poll(() => setupAction.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.type('NoDraftEdits')
expect(await input.innerText()).toBe(prefix)
expect(messages).toBe(0)
await page.keyboard.press('Tab')
expect(await setup.getByRole('button', { name: 'Cancel', exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Shift+Tab')
expect(await setupAction.evaluate(element => element === document.activeElement)).toBe(true)
await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/setup.expected.md', import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
await setup.getByRole('button', { name: 'Later', exact: true }).click()
expect(await setup.count()).toBe(0)
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
expect(recognize).not.toHaveBeenCalled()
preparation = { phase: 'checking', step: 'check', startedAt: 0 }
for (const listener of readinessListeners) listener()
await setupMic.click()
const unavailable = page.getByRole('dialog', { name: 'Speech recognition is not ready', exact: true })
await unavailable.waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/unavailable.expected.md', import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
await page.setViewportSize({ width: 420, height: 900 })
const dialogBox = await unavailable.boundingBox()
expect(dialogBox!.x).toBeGreaterThanOrEqual(0)
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(420)
await page.keyboard.press('Escape')
await unavailable.waitFor({ state: 'hidden' })
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
expect(await input.innerText()).toBe(prefix)
preparation = { phase: 'failed', message: 'Model preparation failed' }
for (const listener of readinessListeners) listener()
await setupMic.click()
const detailsAction = unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true })
await expect.poll(() => detailsAction.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await page.setViewportSize({ width: 1280, height: 900 })
await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
.getByRole('button', { name: 'Retry preparation', exact: true }).waitFor()
expect(await unavailable.count()).toBe(0)
expect(messages).toBe(0)
expect(recognize).not.toHaveBeenCalled()
preparation = { phase: 'standby' }
for (const listener of readinessListeners) listener()
await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
await mic.waitFor()
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').waitFor()
expect(await page.locator('[data-plugin-detail]').count()).toBe(0)
await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
await mic.waitFor()
const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox()
const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox()
+10 -4
View File
@@ -2536,13 +2536,19 @@ export interface JsonRpcConfig {
## `@deepseek-ai/dsh-session-log-deepseek`
- `inject`: `deepseekLlmApiExtensions` · `sessions`
- `source`: [`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts)
- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts)
```ts config-catalog
/** Session-log request contribution configuration. */
export interface Config {
/** Contribute `dsh_session_log` to official DeepSeek requests. Defaults to `true`. */
enabled?: boolean
/**
* Largest serialized `dsh_session_log` field, in UTF-8 bytes, that one request carries.
* A request uploads the longest pending event prefix that fits; later requests continue
* after its acceptance. Defaults to 8 MiB.
*/
maxBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-log-deepseek -->
@@ -3454,7 +3460,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals`
- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
@@ -3658,7 +3664,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-pwsh-persistent`
- `inject`: `tools` · `terminals`
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent pwsh tool. */
@@ -3740,7 +3746,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-str-replace-editor`
- `inject`: `tools` · `fs`
- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts)
```ts config-catalog
/** Configuration for the string-replacement editor tool. */
+10 -4
View File
@@ -2538,13 +2538,19 @@ export interface JsonRpcConfig {
## `@deepseek-ai/dsh-session-log-deepseek`
- `inject`: `deepseekLlmApiExtensions` · `sessions`
- `source`: [`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts)
- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts)
```ts config-catalog
/** Session-log request contribution configuration. */
export interface Config {
/** Contribute `dsh_session_log` to official DeepSeek requests. Defaults to `true`. */
enabled?: boolean
/**
* Largest serialized `dsh_session_log` field, in UTF-8 bytes, that one request carries.
* A request uploads the longest pending event prefix that fits; later requests continue
* after its acceptance. Defaults to 8 MiB.
*/
maxBytes?: number
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-log-deepseek -->
@@ -3456,7 +3462,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals`
- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
@@ -3660,7 +3666,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-pwsh-persistent`
- `inject`: `tools` · `terminals`
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent pwsh tool. */
@@ -3742,7 +3748,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-str-replace-editor`
- `inject`: `tools` · `fs`
- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts)
```ts config-catalog
/** Configuration for the string-replacement editor tool. */
@@ -12,14 +12,14 @@
en: 740e92e903ecb594
zh: f934d3c9e5c56bfe
/official-deepseek-llm-api-wire-extensions/body-extension-transaction:
en: 8c05b525a3f1358e
zh: 4c5bccf12f000038
en: f0b1443c364a431c
zh: 16d16ddf5c451e8e
/official-deepseek-llm-api-wire-extensions/dsh-plugin-packages:
en: 49d6f011e9d985a7
zh: bbf61b4e4480c555
/official-deepseek-llm-api-wire-extensions/dsh-session-log:
en: 5dc19f72ee3db8ab
zh: df9997b805cd01fa
en: de1a3f2c377d74e3
zh: 7c803345f71117c5
/official-deepseek-llm-api-wire-extensions/dsh-session-log/wire-session-header:
en: ccd8fcd773df21c4
zh: 7ea020068ad093ae
@@ -27,8 +27,8 @@
en: 008a21df5e314154
zh: 8855710a37542ec5
/official-deepseek-llm-api-wire-extensions/dsh-session-log/acceptance-watermark-and-at-least-once-delivery:
en: 64ba792a2a6af122
zh: 0f560c3610e07e81
en: 4d84cb1e61f9cf58
zh: 8a63ce3069e2f0b3
/official-deepseek-llm-api-wire-extensions/exposure-and-receiver-requirements:
en: 9000a8295ff540ac
zh: b56802d83a04175d
en: ae93d9a44245b12f
zh: 8b95fc66c6e653c2
+6 -6
View File
@@ -34,7 +34,7 @@ Credential failure happens before anonymous-user-id resolution, so an unauthoriz
The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request.
Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. A composition without the registry sends the unextended base body.
Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure fails the model request even though the endpoint returned 2xx. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension.
@@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
## `dsh_session_log`
[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field, and a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md).
[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field, as does a request whose first pending event alone exceeds `maxBytes` or cannot be serialized; a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md).
```json
{
@@ -110,7 +110,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
| `throughSeq` | non-negative integer | Greatest sequence represented by this request |
| `events` | array | Contiguous events from `afterSeq + 1` through `throughSeq` |
The first upload uses `afterSeq: -1` and carries the complete current log. Each later upload starts after the greatest accepted watermark for the same Session id. The sender snapshots the event array once per request; appends after that snapshot belong to a later request.
The first upload uses `afterSeq: -1`, and each later upload starts after the greatest accepted watermark for the same Session id. Each upload carries the longest contiguous run from that point whose serialized field fits `maxBytes`, 8 MiB by default and counted in UTF-8 bytes including the header and numeric fields, so a backlog above the limit spans several accepted requests. The sender snapshots the event array once per request; appends after that snapshot belong to a later request.
### Wire Session header
@@ -151,12 +151,12 @@ After the endpoint returns HTTP 2xx, the contribution appends this canonical eve
`delivery-accepted` means that the configured endpoint returned HTTP 2xx for the containing LLM request. It does not assert SSE completion or remote persistence. The event's `throughSeq` must identify an earlier event, its `sessionId` identifies the Session whose suffix was sent, and `sessionFormatVersion` binds the watermark to that exact logical generation. Absence means historical format v0.
The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a watermark from another generation cannot authorize the current suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore sends its own complete inherited prefix before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a watermark from another generation cannot authorize the current suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore uploads its own inherited prefix from sequence zero, in requests bounded by `maxBytes`, before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
Transport and non-2xx failures append no watermark. A crash after endpoint acceptance but before local persistence may resend an already accepted range; uncertainty produces duplicates, never a sequence gap. There is no independent upload store, size cap, or truncation path.
Transport failures, non-2xx responses, and requests sent without extension fields after a serialization failure append no watermark. A crash after endpoint acceptance but before local persistence may resend an already accepted range; uncertainty produces duplicates, never a sequence gap. There is no independent upload store or truncation path; `maxBytes` bounds each request's field instead.
## Exposure and receiver requirements
The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. Unless a composition disables it, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and Assistant content, embedded Assistant streams, failed-attempt output, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request.
Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request. While a session-log backlog drains, `throughSeq` trails the Session's latest event, so a 2xx does not show that the receiver holds the current log.
+6 -6
View File
@@ -34,7 +34,7 @@
适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。
系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。组合未挂载注册表时,适配器发送未经扩展的基础正文。
系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。即使端点返回 2xx,接受失败仍会使模型请求失败。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。
@@ -73,7 +73,7 @@
## `dsh_session_log`
[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段,组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。
[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段,第一条待发事件本身超过 `maxBytes` 或无法序列化的请求也会省略该字段;组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。
```json
{
@@ -110,7 +110,7 @@
| `throughSeq` | 非负整数 | 本次请求所表示的最大序号 |
| `events` | 数组 | 从 `afterSeq + 1` 到 `throughSeq` 的连续事件 |
首次上传使用 `afterSeq: -1`,并携带当前的完整日志。此后每次上传都从同一会话 id 的最大已接受水位(watermark)之后开始。发送方为每次请求仅快照一次事件数组;快照后的追加内容属于后续请求。
首次上传使用 `afterSeq: -1`,此后每次上传都从同一会话 id 的最大已接受水位(watermark)之后开始。每次上传携带从该位置起、序列化后能放进 `maxBytes` 的最长连续事件段;`maxBytes` 默认 8 MiB,按 UTF-8 字节计算,包括 header 与数值字段,因此超过上限的积压会分多次已接受的请求传完。发送方为每次请求仅快照一次事件数组;快照后的追加内容属于后续请求。
### Session 协议 header
@@ -151,12 +151,12 @@
`delivery-accepted` 表示已配置端点为包含该字段的 LLM 请求返回 HTTP 2xx。它不表示 SSE 已完整结束,也不表示远端已经持久化。该事件的 `throughSeq` 必须标识一项更早的事件,`sessionId` 标识已发送后缀所属的 Session,`sessionFormatVersion` 则把水位绑定到该逻辑 generation。缺少该字段表示历史格式 v0。
发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,其他 generation 的水位也不能授权当前后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此先发送自身完整的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,其他 generation 的水位也不能授权当前后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此从序号零开始、按 `maxBytes` 分多次请求上传自身的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
传输失败和非 2xx 响应不会追加水位。端点接受后、本地持久化前发生崩溃时,系统可能重新发送已接受范围;不确定性只会产生重复,绝不会产生序号缺口。系统没有独立上传存储、大小上限或截断路径。
传输失败、非 2xx 响应,以及序列化失败后不带扩展字段发出的请求,都不会追加水位。端点接受后、本地持久化前发生崩溃时,系统可能重新发送已接受范围;不确定性只会产生重复,绝不会产生序号缺口。系统没有独立上传存储或截断路径;`maxBytes` 负责限制每次请求的字段大小。
## 暴露内容与接收方要求
请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。除非组合禁用该字段,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 Assistant 内容、嵌入式 Assistant stream、失败 attempt 输出、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。会话日志积压补传期间,`throughSeq` 落后于会话的最新事件,因此 2xx 并不表示接收方已持有当前完整日志。
+6 -3
View File
@@ -808,6 +808,7 @@ flowchart TD
pkg_tool_fs_search --> pkg_timeout
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
pkg_tool_str_replace_editor --> pkg_output_retention
pkg_tool_str_replace_editor --> pkg_sandbox
pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools
@@ -945,10 +946,12 @@ flowchart TD
pkg_shell_env --> pkg_shell
pkg_shell_env --> pkg_tools
pkg_tool_bash_persistent --> pkg_agent
pkg_tool_bash_persistent --> pkg_output_retention
pkg_tool_bash_persistent --> pkg_terminal
pkg_tool_bash_persistent --> pkg_timeout
pkg_tool_bash_persistent --> pkg_tools
pkg_tool_pwsh_persistent --> pkg_agent
pkg_tool_pwsh_persistent --> pkg_output_retention
pkg_tool_pwsh_persistent --> pkg_terminal
pkg_tool_pwsh_persistent --> pkg_timeout
pkg_tool_pwsh_persistent --> pkg_tools
@@ -1586,7 +1589,7 @@ flowchart TD
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
@@ -1616,8 +1619,8 @@ flowchart TD
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+6 -3
View File
@@ -810,6 +810,7 @@ flowchart TD
pkg_tool_fs_search --> pkg_timeout
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
pkg_tool_str_replace_editor --> pkg_output_retention
pkg_tool_str_replace_editor --> pkg_sandbox
pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools
@@ -947,10 +948,12 @@ flowchart TD
pkg_shell_env --> pkg_shell
pkg_shell_env --> pkg_tools
pkg_tool_bash_persistent --> pkg_agent
pkg_tool_bash_persistent --> pkg_output_retention
pkg_tool_bash_persistent --> pkg_terminal
pkg_tool_bash_persistent --> pkg_timeout
pkg_tool_bash_persistent --> pkg_tools
pkg_tool_pwsh_persistent --> pkg_agent
pkg_tool_pwsh_persistent --> pkg_output_retention
pkg_tool_pwsh_persistent --> pkg_terminal
pkg_tool_pwsh_persistent --> pkg_timeout
pkg_tool_pwsh_persistent --> pkg_tools
@@ -1588,7 +1591,7 @@ flowchart TD
| [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
| [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) |
| [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |
@@ -1618,8 +1621,8 @@ flowchart TD
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
+2 -2
View File
@@ -1116,8 +1116,8 @@
en: 93c4403f94ecdc14
zh: 2b2eb0c400881d71
/session-persistence-event-catalog/resolved-persistence-types/sandboxmode:
en: 13071a796a3bfcd2
zh: 93e5fbb1cdf78a79
en: c9fa5343ea99733b
zh: d6de41351c57a73f
/session-persistence-event-catalog/resolved-persistence-types/schedulechange:
en: 16be53be76c16720
zh: 38fd164dde86ccac
+1 -1
View File
@@ -4307,7 +4307,7 @@ One of:
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
Sources: [`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
One of:
+1 -1
View File
@@ -4309,7 +4309,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
来源:[`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
以下类型之一:
+1 -1
View File
@@ -93879,7 +93879,7 @@
"packages/sandbox/sandbox/src/index.ts#SandboxMode"
],
"sources": [
"packages/sandbox/sandbox/src/index.ts:29"
"packages/sandbox/sandbox/src/index.ts:30"
]
},
{
+2 -1
View File
@@ -432,7 +432,8 @@ currentSelection(): ModelSelection
/**
* Save the complete default model selection. A deployment without a configuration
* editor keeps its composition entry.
* editor keeps its composition entry. Saves commit in submission order; a failed
* save rejects its caller without blocking later saves.
* @param next - resolved selection accepted by an entry point.
* @returns fulfillment after the optional profile write settles.
*/
+2 -1
View File
@@ -442,7 +442,8 @@ currentSelection(): ModelSelection
/**
* Save the complete default model selection. A deployment without a configuration
* editor keeps its composition entry.
* editor keeps its composition entry. Saves commit in submission order; a failed
* save rejects its caller without blocking later saves.
* @param next - resolved selection accepted by an entry point.
* @returns fulfillment after the optional profile write settles.
*/
+2 -2
View File
@@ -42,8 +42,8 @@
en: 8b6e6b65ccea943f
zh: bce1edd6386e37b1
/llm-streaming/official-deepseek-request-extensions:
en: fcddc08940bcbeff
zh: cbde6a8fe00d402f
en: b19f291f094157ed
zh: 682f13a833e8bb52
/llm-streaming/service-and-provider-contracts:
en: 8dc79d4cc82824c2
zh: 3ebabd3a117ad717
+1 -1
View File
@@ -774,7 +774,7 @@ interface LlmCallConfigAdapterDefaults {
## Official DeepSeek request extensions
`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request.
`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names.
The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path.
+1 -1
View File
@@ -780,7 +780,7 @@ interface LlmCallConfigAdapterDefaults {
## DeepSeek 官方请求扩展
`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。
`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。
[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。
+2 -2
View File
@@ -820,9 +820,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
@Remote('create') create(request: SessionCreateRequest): Promise<SessionCreateValue>
/**
* Select one Session-local model after explicitly resuming the Session.
* Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
* @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue>
+2 -2
View File
@@ -824,9 +824,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
@Remote('create') create(request: SessionCreateRequest): Promise<SessionCreateValue>
/**
* Select one Session-local model after explicitly resuming the Session.
* Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
* @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue>
+50 -50
View File
@@ -54,8 +54,8 @@
en: e713a4a11a15d13c
zh: e713a4a11a15d13c
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question:
en: 3d5e09e59e7d5274
zh: 624c14da73524315
en: f42b7f17ad59577a
zh: 7e1eee22ff35d4b1
/tool-schema-catalog/deepseek-ai-dsh-tools:
en: 76fbbf0fe38aef10
zh: 76fbbf0fe38aef10
@@ -66,26 +66,26 @@
en: b500b530845e26a9
zh: b500b530845e26a9
/tool-schema-catalog/deepseek-ai-dsh-plan-mode/exit-plan-mode:
en: a4bf2ed3f9f55ff1
zh: ce7b136d3ad3946a
en: b410483c2bd0c1a1
zh: a07ceea6abf1dda3
/tool-schema-catalog/deepseek-ai-dsh-tool-bash:
en: 408ff0a5189bd7ec
zh: 408ff0a5189bd7ec
/tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash:
en: 70add265cf4cfda5
zh: 47c2e23d50557d10
en: 67ee2b8d4997bd1b
zh: 82d1fb17b619c364
/tool-schema-catalog/deepseek-ai-dsh-tool-present:
en: f656575e6df094f6
zh: f656575e6df094f6
/tool-schema-catalog/deepseek-ai-dsh-tool-present/present:
en: 4af0d859e1d39dc5
zh: 780e75b1b42a6410
en: 24ef5efe4c0edf50
zh: a2914d345bd8eefa
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh:
en: b222fc05f3f8ee4a
zh: b222fc05f3f8ee4a
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh/pwsh:
en: 775abe9511776e1d
zh: 76ae33dab0dd399a
en: 9d7055b9fb8ba9de
zh: 0647025c36e80237
/tool-schema-catalog/deepseek-ai-dsh-tool-cordis:
en: 76c7c62f9029b6f5
zh: 76c7c62f9029b6f5
@@ -123,8 +123,8 @@
en: 94bfc5b03c63baef
zh: 339338a0e74696c3
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/read-image:
en: 4e82f36d958c9c1c
zh: 1f87182c327bec10
en: f6f7a1140066ce04
zh: d528e01a0939795a
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/write:
en: f80700c5d57e8d87
zh: fb7cccbba0d8d486
@@ -132,11 +132,11 @@
en: 703fea005ce84c50
zh: 703fea005ce84c50
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/glob:
en: 418d627507ddd693
zh: 85f14b433c93efbe
en: 3ba734ba7852cdd5
zh: c6ce31ff2c195520
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/grep:
en: 224288aaf3fbd214
zh: 9e39dadfaa3768e7
en: 6e1f7da62cc9a35b
zh: ee7f3081887f8067
/tool-schema-catalog/deepseek-ai-dsh-tool-terminal:
en: 77501c1b592b6a88
zh: 77501c1b592b6a88
@@ -162,29 +162,29 @@
en: 74fff3741e7c13de
zh: 74fff3741e7c13de
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/create-goal:
en: b22408124a0a6f34
zh: 7b3bf9ceb5d7f1dc
en: 9ae877cdf8a641a3
zh: f83c3c09e80e4190
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/get-goal:
en: 73ab2b24d4de682d
zh: 82c42a427774c67d
en: 0b517f13dfde11fe
zh: b7ee7482e8ecde56
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/update-goal:
en: a5ef842aa3d1109e
zh: 8f0a9135944e6f1d
en: e6b92fac5aad585c
zh: 6b3cdd87b059a98f
/tool-schema-catalog/deepseek-ai-dsh-schedule:
en: 573da0cf49b1eed6
zh: 573da0cf49b1eed6
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create:
en: 687b784279240ed2
zh: a23fa1b08c15642b
en: 0a10b5db8919d316
zh: 175d680a29f59b63
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete:
en: 3b5a084ce0d87d13
zh: c1356d4b062f0e63
en: cff096695f06e298
zh: 3d0063a1e529377f
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list:
en: 4422ae2e2a4ca897
zh: 56431645118d6ca0
en: d8543c8503d0947e
zh: 205e6c813ad2b37c
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update:
en: a86e8751a34e9f06
zh: 92a32fd4fc3657b4
en: 3d5e2517b510238b
zh: d6e5c4c4465c9645
/tool-schema-catalog/deepseek-ai-dsh-tool-lsp:
en: 32982123b1f0e5bc
zh: 32982123b1f0e5bc
@@ -201,8 +201,8 @@
en: 927a5ac0974b7642
zh: 927a5ac0974b7642
/tool-schema-catalog/deepseek-ai-dsh-tool-skill/skill:
en: cd716dc11308817d
zh: 08b4ed76101642a2
en: 9b159d5831855afb
zh: 275ee82af0e0f59a
/tool-schema-catalog/deepseek-ai-dsh-tool-session-query:
en: eac1dff4b6d9e195
zh: eac1dff4b6d9e195
@@ -228,32 +228,32 @@
en: c89084b31ffccbce
zh: d7b3b7000f2d2be9
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent:
en: df17e5091f9cdfc9
zh: 3b31fe9df04242f2
en: 55cbd29d70bcf3d7
zh: 886a1b57446bd5e1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control:
en: ab82e130c3413f04
zh: ab82e130c3413f04
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent:
en: 3eb5ac28342e354f
zh: 91ca5628d8e333f0
en: 0cec3f2520050839
zh: 06735f086cea84f1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents:
en: e688153e9ba4a684
zh: 56c36cb0140c9202
en: ae22e8543d123c39
zh: 4943a5c5980fc2d3
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message:
en: b4d8ffa51c3c9cce
zh: 32d5281b2dec0d81
en: 2ebdc450faef6446
zh: 7e3cff35fedc6a32
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs:
en: 9be673b1604d0114
zh: 9be673b1604d0114
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-kill:
en: 39e53ddd7ea31c2d
zh: 16640c2952c263b6
en: 14294cc1988ae768
zh: 80a9152bdc02723f
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-list:
en: 0c37fb1fed979d72
zh: 5ea9d07f5a4a8a6c
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output:
en: 2a1d3887688abd05
zh: c1cafbabccc2e021
en: f927cca09b283e21
zh: 2acd9b36737f17c2
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team:
en: 6b1106f417b50db6
zh: 6b1106f417b50db6
@@ -288,14 +288,14 @@
en: dea62d26fb18ff68
zh: dea62d26fb18ff68
/tool-schema-catalog/deepseek-ai-dsh-tool-todo/todo-write:
en: 3e63adceedda7304
zh: bc20a7b69ce2cddd
en: 8c64fe5f2e58a890
zh: e3c780fc842aeb43
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow:
en: bb38a9d29f46563c
zh: bb38a9d29f46563c
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow/workflow:
en: 4ca5c5f9ebc188e5
zh: 98bbc45e441019af
en: fdd667cfe21481e8
zh: 88864d3b958398a4
/tool-schema-catalog/deepseek-ai-dsh-tool-workspace-dependencies:
en: 96bffc40b80df4b8
zh: 96bffc40b80df4b8
@@ -309,5 +309,5 @@
en: 8673f203aea9bdf2
zh: 4e17783ffdf34c83
/tool-schema-catalog/deepseek-ai-dsh-tool-web/web-search:
en: 096d6a19c83af773
zh: fa5213caf9401216
en: cc1ad38bf0006710
zh: 324eacb827a09e93
+59 -62
View File
@@ -449,7 +449,7 @@ Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../p
### `ask_user_question`
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.
```json
{
@@ -571,7 +571,7 @@ Owned by the tool registry as a reserved transport outside filterable capability
### `exit_plan_mode`
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
```json
{
@@ -598,7 +598,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
@@ -642,7 +642,7 @@ The bash tool is the model-facing consumer of the bash executor seam. With a job
### `present`
Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.
```json
{
@@ -650,6 +650,7 @@ Declare selected existing files accessible through the Session filesystem as fin
"properties": {
"files": {
"type": "array",
"description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -685,7 +686,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]`, a policy denial: do not retry another way.
```json
{
@@ -958,7 +959,7 @@ Edit an existing UTF-8 text file by replacing literal text.
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
"description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1010,7 +1011,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
### `read_image`
Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.
Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.
```json
{
@@ -1063,7 +1064,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-p
### `glob`
Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.
Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result is sampled across top-level entries and reports where the complete list was saved.
```json
{
@@ -1088,7 +1089,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc
### `grep`
Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.
Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.
```json
{
@@ -1288,7 +1289,7 @@ The six terminal tools are opt-in and complement one-shot shell/filesystem tools
### `create_goal`
Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.
Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say "goal"; not for single-turn work.
```json
{
@@ -1313,7 +1314,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `get_goal`
Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.
Read the current session goal, including the id and revision that update_goal requires.
```json
{
@@ -1326,7 +1327,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `update_goal`
Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.
Update the current goal.
```json
{
@@ -1342,7 +1343,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1361,7 +1362,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
"description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1382,7 +1383,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
### `schedule_create`
Create one reminder in the current session. Supply a non-empty prompt, a title, and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 60, daily as {time: "23:00:00", time_zone: "Asia/Shanghai"}, weekly as {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} with Monday 1 through Sunday 7, or cron as {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} with the five fields minute hour day-of-month month day-of-week. Every creation requires a title of at most 120 characters, non-empty after trimming; it names the task on its card, its detail heading, and in the task lists. Daily, weekly, and cron reminders retain that local time and zone; missing wall-clock times skip the date and repeated times use only the earlier instant. A cron day-of-month and day-of-week pair matches when either field matches once both are restricted. Fixed-rate targets stay creation-aligned until an interval edit establishes a new anchor. All four recurring kinds batch one latest occurrence per overdue rule. The Host restores this session when a reminder is due. After downtime, each recurring reminder delivers its latest missed occurrence once. Delivery can repeat after a crash.
Create a reminder in the current session that delivers prompt when it becomes due. Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash.
```json
{
@@ -1394,19 +1395,19 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"title": {
"type": "string",
"description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
"description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
"description": "Positive safe-integer delay in seconds."
"description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1425,7 +1426,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1438,7 +1439,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1452,12 +1453,12 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1495,7 +1496,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1509,7 +1510,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_delete`
Delete one retained reminder in the current session by its exact id, whether active or inactive. Unknown or already-deleted ids return deleted false. Deletion does not retract a queued message.
Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.
```json
{
@@ -1517,7 +1518,7 @@ Delete one retained reminder in the current session by its exact id, whether act
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id."
"description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1530,7 +1531,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_list`
List every active reminder in the current session, including its exact id, title, UTC target, scheduled or overdue state, and host delivery mode. The returned order is not significant.
List the active reminders in the current session.
```json
{
@@ -1543,7 +1544,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_update`
Change one reminder in the current session in place, keeping its id and its saved delivery records: address it by the exact id schedule_list returned, then supply a new title or prompt, or exactly one new selector from at, every_seconds, daily, weekly, or cron in the same forms schedule_create accepts. An omitted field keeps its stored value. After is not updatable: create a new reminder for a relative delay. The Host compares the record it finds for that id with the stored one, so a concurrent edit returns schedule_conflict instead of overwriting it; an inactive or unknown reminder returns updated false. Editing an every_seconds interval anchors the new fixed rate at the accepted save time; a name or instruction change alone keeps the committed target.
Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; omitted fields keep their stored values. To change a relative delay, create a new reminder.
```json
{
@@ -1551,23 +1552,23 @@ Change one reminder in the current session in place, keeping its id and its save
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id that schedule_list returned for one reminder."
"description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
"description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
"description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
"description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
"description": "New reminder content."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1586,7 +1587,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1599,7 +1600,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1613,12 +1614,12 @@ Change one reminder in the current session in place, keeping its id and its save
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1656,7 +1657,7 @@ Change one reminder in the current session in place, keeping its id and its save
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1754,7 +1755,7 @@ A fixed foreground workflow starts one fresh structured child per round; the mod
### `skill`
Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.
Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog.
```json
{
@@ -2036,7 +2037,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
### `subagent`
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This call waits for the result by default.
```json
{
@@ -2052,7 +2053,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
"description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2072,7 +2073,7 @@ The registered delegation name is the load-time `toolName` config (default `suba
### `interrupt_agent`
Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.
Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a direct child's conversation later with send_message. Subagents it started will keep running.
```json
{
@@ -2080,7 +2081,7 @@ Request cancellation of a background agent's current turn by its agent id. The t
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
"description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2093,7 +2094,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation.
```json
{
@@ -2101,7 +2102,7 @@ List your continuable background subagents by durable id and label. Use it to re
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2115,7 +2116,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag
### `send_message`
Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered.
Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer.
```json
{
@@ -2147,7 +2148,7 @@ The globally named control tools over continuable background subagents: provider
### `job_kill`
Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.
Request cancellation of a running background job.
```json
{
@@ -2185,7 +2186,7 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/
### `job_output`
Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.
Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job.
```json
{
@@ -2197,11 +2198,11 @@ Read a background job. Stream jobs return only output since the previous read; f
},
"wait": {
"type": "boolean",
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
"description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
"description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2510,7 +2511,7 @@ All nine tools are scoped to implicit Team Leads and durable teammates. The ship
### `todo_write`
Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).
Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done.
```json
{
@@ -2562,17 +2563,13 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c
Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.
Script-body hooks:
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.
- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides.
- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages.
- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.
- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`).
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work.
```json
{
@@ -2580,11 +2577,11 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
"description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return <value>`; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2697,7 +2694,7 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
### `web_search`
Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.
Search the web for current information. Returns an optional summary answer and a list of source URLs.
```json
{
@@ -2705,7 +2702,7 @@ Search the web for current information. Provide 1–4 queries in the required qu
"properties": {
"queries": {
"type": "array",
"description": "Required search queries; accepts 1–4 items and merges their results.",
"description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
+59 -62
View File
@@ -453,7 +453,7 @@
### `ask_user_question`
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。发送一个或多个问题,每个问题都带一个稳定 id,该 id 会在答案中原样返回。
继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。
```json
{
@@ -575,7 +575,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `exit_plan_mode`
仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。发送**完整的** Markdown 计划,以一个为计划命名的 # 标题开头。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
```json
{
@@ -602,7 +602,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `bash`
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -646,7 +646,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `present`
选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
将已有文件声明为交付给用户的最终交付物。当用户需要独立文件时使用,尤其是 Office 文档、电子表格和演示文稿;如果最终回复已经足够,优先使用最终回复。用户打开的是当前文件;不复制其内容。
```json
{
@@ -654,6 +654,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
"properties": {
"files": {
"type": "array",
"description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -689,7 +690,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -964,7 +965,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
"description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1016,7 +1017,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `read_image`
读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。无扩展名的路径同样被接受;格式按文件内容检测,因此规范化附件路径可以直接传入,无需复制或重命名。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。
读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。大图会自动缩小;不要为了查看图片而安装图片库或创建缩略图。
```json
{
@@ -1069,7 +1070,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `glob`
查找路径匹配 glob 模式的文件。只返回匹配的文件路径,绝不返回目录;包括隐藏文件和被忽略的文件,但排除 VCS 元数据目录。最多按修改时间顺序返回 100 条路径;如果结果更多,则改为返回从顶层条目中抽样的 100 条路径,说明已抽样,并报告完整排序列表的保存位置。该工具不枚举目录条目。
查找路径匹配 glob 模式的文件(不含目录),包括隐藏文件和被忽略的文件。最多按修改时间顺序返回 100 条路径;更大的结果会从顶层条目中抽样,并报告完整列表的保存位置。
```json
{
@@ -1094,7 +1095,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `grep`
使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。前 250 条匹配会直接返回;结果达到上限时会报告完整匹配列表的保存位置。如需周边上下文,请对匹配的文件使用 read。
使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。最多返回 250 条匹配;更大的结果会报告完整匹配列表的保存位置。
```json
{
@@ -1294,7 +1295,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `create_goal`
当当前直接人类请求是需要跨自主 Goal Round 持续推进的长期目标时,创建一个持久化的同会话完成目标。即使用户没有明确说「创建目标」,你也可以推断其意图。不要用于简单的单轮工作。执行时会拒绝非人类权限和 subagent 权限。
创建一个持久化目标,使当前会话跨自动延续 Round 持续工作。当直接人类请求是长期目标时使用,即使用户没有说「目标」;不要用于单轮工作。
```json
{
@@ -1319,7 +1320,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `get_goal`
读取当前的同会话目标,包括确切的 id/revision、目标、阶段、已完成的延续 Round 数、Round 上限、存在时的阻塞原因,以及是否已准备下一次延续。更新目标前请先调用此工具。
读取当前会话目标,包括 update_goal 所需的 id 和 revision。
```json
{
@@ -1332,7 +1333,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `update_goal`
更新确切的当前目标 revision。edit、pause 和 resume 要求直接的顶层人类请求。在自动延续当前目标期间,也允许 complete 和 blocked。在达到配置的最小 Round 数之前会拒绝 blocked;模型仍须判断相同条件是否在这些 Round 中持续存在,并在 blocked_reason 中予以说明。
更新当前目标。
```json
{
@@ -1348,7 +1349,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1367,7 +1368,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
"description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1388,7 +1389,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_create`
在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 60 的安全整数 every_seconds;形如 {time: "23:00:00", time_zone: "Asia/Shanghai"} 的 daily;形如 {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} 的 weekly,其中周一为 1、周日为 7;或形如 {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} 的 cron,其五个字段为 minute hour day-of-month month day-of-week。每次创建都必须提供标题,最多 120 个字符且去除首尾空白后非空;该标题用于任务卡片、详情标题和任务列表。每日、每周与 cron 提醒保留指定的本地时间与时区;不存在的钟表时间会跳过该日期,重复时刻只取较早实例。cron 的 day-of-month 与 day-of-week 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。
在当前会话中创建一条提醒,到期时投递 prompt。请恰好提供一个时间参数:after_seconds、at、every_seconds、daily、weekly 或 cron。时区中不存在的本地时间会被跳过;重复出现的本地时间只在较早的时刻触发一次。停机后,重复提醒只投递最近错过的一次。崩溃后可能重复投递。
```json
{
@@ -1400,19 +1401,19 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"title": {
"type": "string",
"description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
"description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
"description": "Positive safe-integer delay in seconds."
"description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1431,7 +1432,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1444,7 +1445,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1458,12 +1459,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1501,7 +1502,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1515,7 +1516,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_delete`
使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。
删除当前会话中的一条提醒,活动或已结束的均可。删除不会撤回已经入队的提醒消息。
```json
{
@@ -1523,7 +1524,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id."
"description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1536,7 +1537,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_list`
列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。
列出当前会话中的活动提醒。
```json
{
@@ -1553,7 +1554,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_update`
在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。
原地修改一条提醒并保留其 id。提供新的 title、prompt,或至多一个时间参数;未提供的字段保持原值。需要相对延迟时请新建一条提醒。
```json
{
@@ -1561,23 +1562,23 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
"description": "Exact schedule id that schedule_list returned for one reminder."
"description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
"description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
"description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
"description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
"description": "New reminder content."
},
"every_seconds": {
"type": "number",
"description": "Fixed-rate safe-integer interval in seconds, at least 60."
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
"description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
"description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1596,7 +1597,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
"description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
"description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1609,7 +1610,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
"description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1623,12 +1624,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
"description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
"description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1666,7 +1667,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
"description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1764,7 +1765,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `skill`
加载可用 skill(技能)的完整说明。在执行点名某项 skill 或与其明确匹配的任务前,请使用会话 skill 目录中的确切名称调用此工具。
加载某项 skill(技能)的完整说明。在执行点名某项 skill 或与会话 skill 目录中某项 skill 明确匹配的任务前,请调用此工具。
```json
{
@@ -2046,7 +2047,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `subagent`
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此调用默认等待结果。
```json
{
@@ -2062,7 +2063,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
"description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2082,7 +2083,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `interrupt_agent`
根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。
请 subagent 停止当前工作。此调用不等待其停止即返回。之后可以用 send_message 继续与直接子级的对话。它启动的 subagent 会继续运行。
```json
{
@@ -2090,7 +2091,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
"description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2103,7 +2104,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
列出你启动的 subagent 及其 id、标签和状态。running 表示正在工作;inactive 表示当前未在工作。subagent 完成时你会收到通知,无需反复查看状态。使用 send_message 继续对话。
```json
{
@@ -2111,7 +2112,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2125,7 +2126,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `send_message`
根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。
向某个 agent 发送消息。工作中的 agent 会在下一个 step 收到消息;空闲的 agent 会以该消息开始新一轮。返回投递确认,而不是该 agent 的答案。
```json
{
@@ -2157,7 +2158,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_kill`
根据 job id 请求取消正在运行的后台任务。此调用立即返回;任务的工作真正停止后,会以 killed 状态结算。
请求取消正在运行的后台任务。
```json
{
@@ -2195,7 +2196,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_output`
读取后台任务。流式任务只返回自上次读取以来的输出;最终输出任务会在结算后返回结果。每个响应都以 `[status: ...]` 结尾。读取默认不阻塞;设置 `wait: true` 后,最长等待到配置的上限。
读取后台任务:流式任务返回自上次读取以来的输出,已完成的最终输出任务返回其结果。
```json
{
@@ -2207,11 +2208,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"wait": {
"type": "boolean",
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
"description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
"description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2521,7 +2522,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `todo_write`
记录并更新当前工作的结构化任务列表。每次调用都要发送**完整列表**,它会**替换**之前的列表,不支持局部更新或逐项编辑。请用它规划多步骤工作并展示进度:开始前为每个具体步骤添加一项 todo。将当前正在处理的每项 todo 标记为 `in_progress`;确实并行运行时(例如并发 subagent 或后台命令)可同时标记多项,顺序工作则标记 1 项。只要工作尚未完成,就应至少有一项任务为 `in_progress`。某项 todo 完成后立即标记为 `completed`,不要批量标记完成;只有全部工作完成后,才可以没有 `in_progress` 项。简单的单步骤任务无需使用列表。状态:`pending`(未开始)、`in_progress`(正在处理)、`completed`(已完成)。
记录并更新任务列表,用于规划多步骤工作并展示进度;简单的单步骤任务无需使用。开始前为每个具体步骤添加一项 todo。只要工作尚未完成,就将正在处理的 todo 标记为 `in_progress`,仅在工作并行运行时同时标记多项。某项 todo 完成后立即标记为 `completed`。
```json
{
@@ -2573,18 +2574,14 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
运行用于大规模编排 subagent 的 JavaScript 工作流脚本。当工作会分散到许多相互独立的部分时,请使用此工具,例如审查大量文件、执行迁移、开展多角度研究或对发现进行对抗式验证;此时应将编排写成脚本,而不是逐轮委派。
工作流的身份通过 `meta` 参数以 JSON 形式传入:必填的 `name`(简短 kebab-case)和 `description` 字符串,以及可选的 `whenToUse` 字符串和 `phases` 数组(`{title, detail?, provider?, model?}`)。`script` 参数只能是纯 JavaScript **函数体**,不能是 TypeScript,也不能包含 `export const meta` 语句;meta 是参数而非代码。脚本支持顶层 await;请以 `return <value>` 结尾,该值必须可以 JSON 序列化,并作为此工具的结果。
脚本函数体提供以下钩子:
- `agent(prompt, opts?): Promise<any>`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,不支持 pattern/format/数值边界,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项,两者可单独提供。其他任何选项(`effort`/`isolation`/`agentType`)都会明确报错。
- `pipeline(items, ...stages): Promise<any[]>`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。普通的阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
- `agent(prompt, opts?): Promise<any>`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项。
- `pipeline(items, ...stages): Promise<any[]>`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
- `parallel(thunks): Promise<any[]>`:并发运行零参数函数并等待**全部**完成。它会形成屏障,仅当某个阶段确实需要汇总全部先前结果时使用。抛出异常的 thunk 解析为 `null`。
- `phase(title)`:开始一个进度阶段;`log(message)`:说明进度;`args`:工具调用的 `args` 输入,原样提供。
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),整个脚本会终止,而不会产生 `null`。脚本没有文件系统、网络、定时器或 Node.js API;具体工作由 agent 完成。
```json
{
@@ -2592,11 +2589,11 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
"description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return <value>`; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2709,7 +2706,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
### `web_search`
在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。
在 Web 上搜索最新信息。返回可选的摘要答案和来源 URL 列表。
```json
{
@@ -2717,7 +2714,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"queries": {
"type": "array",
"description": "Required search queries; accepts 1–4 items and merges their results.",
"description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
+2 -2
View File
@@ -24,5 +24,5 @@
en: c63ca1e3a99e6fe1
zh: 2ceddea3f30d92d3
/get-started-with-the-python-sdk/understand-the-minimal-profile:
en: a741bfb098e18d7b
zh: 318c82836bdc19bd
en: 5eaacc1bc52853c4
zh: 5b94bfc79aac04d8
+1 -1
View File
@@ -177,7 +177,7 @@ Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSe
| Runtime context and compaction | Absent |
| Session persistence | Uncompressed JSONL under `<dsh_home>/sessions` |
The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads complete unaccepted log suffixes with DeepSeek requests by default; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container.
The profile's sole bundle inserts the complete tree over an empty root and does not include `dsh-base`; later base-profile tools therefore cannot appear implicitly. It contains the SDK protocol, one environment-configured DeepSeek adapter, local execution, and persistence, while filesystem tools, settings, managed credentials, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. It pins `danger-full-access`, so the platform-selected persistent shell can modify any path visible to the runtime; use a disposable checkout or container.
The installed wheel still packages the full `web` profile and frontend assets. Run `dsh web` against an explicit `DSH_HOME` when a Python SDK deployment also needs the browser application; `web` is a separate CLI application and cannot serve a Python SDK client.
+1 -1
View File
@@ -177,7 +177,7 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle
| 运行时上下文与 compaction | 不存在 |
| 会话持久化 | `<dsh_home>/sessions` 下的未压缩 JSONL |
该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传完整的未接受日志后缀;在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。
@@ -21,8 +21,8 @@
en: 41bdda1ac32df558
zh: aa5d0e325924066b
/session-controller/session-media-references:
en: c7e6bbb57a4894b7
zh: f66d79776a7e33ec
en: 266ab2b1e2fec542
zh: b3450ae28a1e0baf
/session-controller/configuration:
en: 5c4c4224074e38a8
zh: e0b0edac65c88ff4
@@ -80,6 +80,8 @@ References keep local Session data, scoped Contexts, and history streams alive,
GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection.
A successful `selectModel` response acknowledges the Session-local selection without waiting for the default profile setting to save. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. New Sessions read the last successfully saved default.
-----
<a id="configuration"></a>
@@ -80,6 +80,8 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以
GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。
`selectModel` 成功返回表示会话级模型选择已生效,不等待默认 profile 设置保存。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。新会话读取最近一次成功保存的默认值。
-----
<a id="configuration"></a>
@@ -144,9 +144,9 @@ export class SessionCommandController {
}
/**
* Validate and install one Session-local model selection.
* Validate and install one Session-local model selection; save the default in the background.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
* @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
async selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
const agent = await this.resolveAgent(request.sessionId)
@@ -168,13 +168,11 @@ export class SessionCommandController {
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectForNextRequest(agent, selected)
try {
await this.ctx.agentDefaultModel.saveSelection(selected)
} catch (error) {
void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => {
this.ctx.logger.warn(
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
)
}
})
return { selected: { ...selected } }
} catch (error) {
if (remoteErrorOf(error) !== undefined) throw error
+2 -2
View File
@@ -275,9 +275,9 @@ export class SessionController extends TypertRemoteService {
}
/**
* Select one Session-local model after explicitly resuming the Session.
* Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
* @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel')
selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
@@ -5,7 +5,7 @@
* boundary for a running selection change.
*/
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -617,8 +617,40 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('accepts consecutive model switches while default persistence is blocked', async () => {
const { ctx, sessionId } = await harness()
const release = Promise.withResolvers<undefined>()
const saves: Promise<void>[] = []
const operations: Promise<unknown>[] = []
onTestFinished(async () => {
release.resolve(undefined)
await Promise.allSettled([...operations, ...saves])
await ctx.fiber.dispose()
})
const remote = createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: () => {
saves.push(release.promise)
return release.promise
},
cwd: '/tmp',
})
for (const model of ['deepseek-reasoner', 'deepseek-chat']) {
let returned = false
const operation = remote.selectModel({ sessionId, provider: 'deepseek-official', model })
.then((result) => { expectValue(result); returned = true })
operations.push(operation)
await expect.poll(() => returned).toBe(true)
expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model })
}
expect(saves).toHaveLength(2)
})
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warn.mockRestore() })
const saved: unknown[] = []
let reject = false
const remote = createSessionTestRemote(ctx, {
@@ -650,6 +682,9 @@ describe('Web session model selection', () => {
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
await expect.poll(() => warn.mock.calls).toContainEqual([
'session-controller: model selection changed for the Session but the default was not saved: Error: read-only document',
])
await ctx.fiber.dispose()
})
@@ -27,8 +27,8 @@
en: 93ca9e832fbfd632
zh: 69412adba259c6f8
/deepseek-ai-dsh-api-workspace-files/use-this-package/file-read-and-directory-checks:
en: 4db14c7ce7bc3390
zh: fa97e79fb2c07ce2
en: aa85c7754557a22f
zh: c576d06749fbe0a5
/deepseek-ai-dsh-api-workspace-files/use-this-package/the-change-feed:
en: 8bd2d820a512d461
zh: 0614ddd842ae7526
+1 -1
View File
@@ -51,7 +51,7 @@ Pass the required options object as `{}` for a complete file under `maxFileBytes
### File-read and directory checks
File reads and directory listing first use `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. Directory listing and watching require the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
Every operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires it to resolve to a directory inside the workspace root, so a linked directory lists like its target; `changes` confines the watched directory the same way. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
### The change feed
+1 -1
View File
@@ -51,7 +51,7 @@ kind: "package-reference"
### 文件读取与目录检查
文件读取和目录列举先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。目录列举和监听要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接——任何平台上的目录符号链接,包括 Windows 目录联接——要求它解析为工作区内的目录,因此被链接的目录与其目标一样可列举;`changes` 也以同样方式把被监听的目录限制在工作区内。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
### 变更流
+10 -1
View File
@@ -302,7 +302,10 @@ export class WorkspaceFiles extends TypertRemoteService {
@Remote
async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing> {
const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
if (entry.type !== 'directory') {
// A final link — a Windows junction or a symlink — is listed through the
// directory it resolves to, matching the child type `listDir` reports for
// that entry; `read` keeps its own no-follow gate on the final component.
if (entry.type !== 'directory' && entry.type !== 'symlink') {
throw new RemoteError(
'workspace-file/not-directory',
`"${path}" is a ${entry.type}`,
@@ -310,6 +313,12 @@ export class WorkspaceFiles extends TypertRemoteService {
)
}
const target = await this.confine(root, workspaceRoot, path, signal)
if (entry.type === 'symlink') {
const info = await this.ctx.fs.stat(target, signal)
if (info?.type !== 'directory') {
throw new RemoteError('workspace-file/not-directory', `"${path}" does not resolve to a directory`, { path, kind: 'symlink' })
}
}
const children = await this.ctx.fs.listDir(target, signal)
return {
path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
@@ -1,6 +1,6 @@
/** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, symlink, writeFile } from 'node:fs/promises'
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
@@ -20,6 +20,10 @@ afterEach(async () => {
const endpoint = (caps?: { maxEntries?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
/** Create a link to a directory: a Windows junction where junctions exist, a directory symlink elsewhere. */
const directoryLink = (target: string, link: string): Promise<void> =>
symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir')
describe('workspaceFiles.list — the happy path', () => {
it('lists the root as the empty workspace path, with types and file sizes', async () => {
await mkdir(join(workspace, 'src'))
@@ -64,6 +68,15 @@ describe('workspaceFiles.list — the happy path', () => {
{ name: 'to-file', type: 'file', size: 1 },
])
})
it('lists a directory link whose target is inside the workspace', async () => {
await mkdir(join(workspace, 'real-dir'))
await writeFile(join(workspace, 'real-dir', 'child.txt'), 'x', 'utf8')
await directoryLink(join(workspace, 'real-dir'), join(workspace, 'dir-link'))
const listing = await endpoint().list(harness.scope, 'dir-link', signal())
expect(listing.path).toBe('real-dir')
expect(listing.entries).toEqual([{ name: 'child.txt', type: 'file', size: 1 }])
})
})
describe('workspaceFiles.list — the entry cap', () => {
@@ -93,11 +106,19 @@ describe('workspaceFiles.list — gates', () => {
expect(failure.code).toBe('workspace-file/outside-workspace')
})
it('rejects a symlinked directory before following it, wherever it points', async () => {
await symlink(outside, join(workspace, 'escape'))
it('rejects a directory link that resolves outside the workspace', async () => {
await directoryLink(outside, join(workspace, 'escape'))
const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal()))
expect(failure.code).toBe('workspace-file/outside-workspace')
})
it('rejects a directory link whose target is gone', async () => {
await mkdir(join(workspace, 'vanishing'))
await directoryLink(join(workspace, 'vanishing'), join(workspace, 'dangling'))
await rm(join(workspace, 'vanishing'), { recursive: true })
const failure = await failureOf(endpoint().list(harness.scope, 'dangling', signal()))
expect(failure.code).toBe('workspace-file/not-directory')
expect(failure.details).toMatchObject({ kind: 'symlink' })
expect(failure.details).toMatchObject({ path: 'dangling', kind: 'symlink' })
})
it('rejects a file, which has no children to list', async () => {
+2 -2
View File
@@ -24,8 +24,8 @@
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
en: faa66e7b3b641fa1
zh: c54b742abb967726
en: d50b0fdaf62353dd
zh: 0fccb6968602e5de
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
en: 646a0ccdd7881ad1
zh: e3696acb1573dd7b
+2 -2
View File
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures skip that bundle without changing its selection; the loaded profile lists each skip in `skippedBundles`, and the launcher prints them once per start with `reportSkippedBundles`. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one and listed in `skippedBundles`. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
+2 -2
View File
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会跳过该组合包,不改变其选择状态;加载结果在 `skippedBundles` 中列出每个被跳过的组合包,启动器每次启动时通过 `reportSkippedBundles` 输出一次。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过,并列入 `skippedBundles`。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
@@ -1,33 +1,30 @@
/** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { composeEntries, createRuntimeResolution, readProfileManifest, skippedProfileBundles, type Profile } from '../profile.ts'
import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts'
import { collectConfigSchemas } from './collect.ts'
import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts'
/**
* Generate JSON Schema for a prepared profile's ordered patch layers without mounting plugins or evaluating expressions.
* Reads the profile manifest; imports, Config getters, and lazy builders execute trusted code. Native validators and
* Imports, Config getters, and lazy builders execute trusted code. Native validators and
* transform callbacks are not executed. Calls must not overlap another profile-resolution interception; collection
* releases its interception on success or rejection, while Node retains imported modules. Supplied layers are not mutated.
* Profile preparation, layer selection, process streams, and exit policy belong to the caller.
* @param binName - the diagnostic prefix on thrown manifest errors.
* @param profile - prepared on-disk profile whose directory anchors root module and include resolution.
* @param layers - already parsed patch lists in application order, including caller-selected home and argv overlays.
* @param installAnchor - package manifest anchoring the installation's runtime dependencies.
* @returns a JSON Schema document with declaration references, partial results, and diagnostics under `x-cordis`.
* @throws when profile metadata, composition, or runtime resolution cannot be prepared.
* @throws when composition or runtime resolution cannot be prepared.
*/
export async function generateConfigSchema(
binName: string,
profile: Profile,
layers: readonly PatchOptions[][],
installAnchor: string,
): Promise<ConfigSchemaDump> {
const diagnostics: ConfigSchemaDiagnostic[] = []
const manifest = readProfileManifest(binName, profile.dir)
for (const packageName of skippedProfileBundles(profile, manifest)) {
for (const { packageName } of profile.skippedBundles) {
diagnostics.push({
level: 'error',
message: `Selected profile bundle ${JSON.stringify(packageName)} could not be loaded; repair or remove its bundle selection.`,
+2
View File
@@ -68,11 +68,13 @@ export {
PROFILE_TEMPLATES,
PROFILES_DIR,
readProfileManifest,
reportSkippedBundles,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type Profile,
type ProfileLayer,
type SkippedBundle,
type ProfileManifest,
type LinkedRoot,
type RuntimeResolutionOptions,
+28 -18
View File
@@ -98,6 +98,27 @@ export interface Profile {
patchPath: string
/** The profile's own patches; empty when the file is absent. */
patches: PatchOptions[]
/** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
skippedBundles: SkippedBundle[]
}
/** A selected bundle the profile could not load, or whose own DSH peers the profile does not exempt. */
export interface SkippedBundle {
/** The bundle's package name from `dsh.profile.bundles`. */
packageName: string
/** The resolution, manifest, compatibility, or patch-loading failure. */
reason: string
}
/**
* Print each skipped bundle once; loading never prints, so launchers call this once per start.
* @param binName - the diagnostic prefix.
* @param profile - the loaded profile.
*/
export function reportSkippedBundles(binName: string, profile: Pick<Profile, 'skippedBundles'>): void {
for (const { packageName, reason } of profile.skippedBundles) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`)
}
}
/** One package the runtime resolution supplies at the interception layer. */
@@ -413,7 +434,7 @@ export async function createRuntimeResolution(
const profilesDir = join(home, PROFILES_DIR)
const manifest = readOptionalProfileManifest(profile)
const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(
installAnchor, skippedProfileBundles(profile, manifest),
installAnchor, new Set(profile?.skippedBundles.map(skipped => skipped.packageName)),
)
const profileDeclarers = new Map<string, string>()
const profileVersions = new Map<string, string | undefined>()
@@ -452,18 +473,6 @@ function readOptionalProfileManifest(profile: Profile | undefined): ProfileManif
}
}
/**
* Identify selected bundles that did not produce a loaded layer.
* @param profile - loaded profile, when present.
* @param manifest - its parsed manifest, when present.
* @returns selected bundle names missing from the loaded layers, for resolution and diagnostics.
*/
export function skippedProfileBundles(profile: Profile | undefined, manifest: ProfileManifest | undefined): ReadonlySet<string> {
const selected = manifest?.dsh?.profile?.bundles ?? []
const loaded = new Set(profile?.layers.map(layer => layer.packageName))
return new Set(selected.filter(name => !loaded.has(name)))
}
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
if (manifest === undefined) return []
@@ -634,8 +643,8 @@ export function resolveBundleDir(
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported
* on stderr and skipped without changing the manifest.
* Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped
* without changing the manifest and listed in `skippedBundles`; nothing is printed.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
@@ -651,6 +660,7 @@ export function loadProfileDirectory(
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers: ProfileLayer[] = []
const skippedBundles: SkippedBundle[] = []
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
for (const packageName of bundles) {
try {
@@ -667,20 +677,20 @@ export function loadProfileDirectory(
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
layers.push({ packageName, packageDir, patchPaths, patches })
} catch (error) {
process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`)
skippedBundles.push({ packageName, reason: String(error) })
}
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
return { name: basename(dir), dir, layers, patchPath, patches }
return { name: basename(dir), dir, layers, patchPath, patches, skippedBundles }
}
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
* are reported on stderr and skipped; profile manifest and user patch errors still throw.
* are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more