From 931505a9cc4a6ab6e877f5a85a2dd05437e01da5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 18 Sep 2026 04:09:03 +0800 Subject: [PATCH 001/282] fix(workspace-files): list directory links through their resolved target Windows directory junctions are reparse points that lstat reports as symlinks, so list refused My Music, My Pictures and My Videos with workspace-file/not-directory while its own listing reported them as directories. list now follows a final link, requires the resolved target to be a directory inside the workspace root, and keeps read's no-follow gate. --- ...26-09-05-workspace-files-service.i18n.yaml | 4 +-- .../2026-09-05-workspace-files-service.md | 8 ++--- .../2026-09-05-workspace-files-service.zh.md | 8 ++--- ...ndows-directory-junction-listing.i18n.yaml | 6 ++++ ...9-18-windows-directory-junction-listing.md | 27 +++++++++++++++++ ...8-windows-directory-junction-listing.zh.md | 27 +++++++++++++++++ packages/api/workspace-files/README.i18n.yaml | 4 +-- packages/api/workspace-files/README.md | 2 +- packages/api/workspace-files/README.zh.md | 2 +- packages/api/workspace-files/src/index.ts | 11 ++++++- .../api/workspace-files/tests/list.spec.ts | 29 ++++++++++++++++--- 11 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..8b3394223d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: b8301a423c0c9cf69a177f079de38dff0b112cd4 +2026-09-05-workspace-files-service.zh.md: c01324db5aa3dc0112604f297dcc4782d42347a4 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..b8301a423c 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -43,7 +43,7 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`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 one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. - **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their 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 @@ -56,12 +56,12 @@ Two path vocabularies leave the service, and each method uses exactly one. `read `read`, `readBytes`, `readAll`, `readRelated`, 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 @@ -142,7 +142,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 base64, 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 base64, 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 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..c01324db5a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -43,7 +43,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`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 - **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;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`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 @@ -56,12 +56,12 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `read`、`readBytes`、`readAll`、`readRelated` 与 `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` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。 ### 失败 @@ -142,7 +142,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、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `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、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml new file mode 100644 index 0000000000..fb7a54a51e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md +2026-09-18-windows-directory-junction-listing.md: 8e0edc0b997c1b5526395de97d380daa7bc86e48 +2026-09-18-windows-directory-junction-listing.zh.md: 3236c063d667add26f400506c775424f095b699a diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md new file mode 100644 index 0000000000..8e0edc0b99 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md new file mode 100644 index 0000000000..3236c063d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md @@ -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 两端都跑。 diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 4bbf6a7a48..c43627f94d 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: f7e40138d8b790f993e3cc8a15d4216ca966cabb -README.zh.md: 04548bed7ffc1698e9a05ffbc93157ea07d6f932 +README.md: b4490d385f3a34251920534bb57e6d84deeda729 +README.zh.md: 1c11e14b1a5bec54d446f367474e14720a3a478f diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f7e40138d8..b4490d385f 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -51,7 +51,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and ### File-read and directory checks -Every operation first uses `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. `list` alone requires 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 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 what it resolves to be a directory inside the workspace root, so a linked directory lists like its target. 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 path is a `gateway/bad-request`. ### The change feed diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 04548bed7f..1c11e14b1a 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 文件读取与目录检查 -每项操作都先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。只有 `list` 要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 +每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接(任何平台上的目录符号链接,包括 Windows 目录联接),要求解析结果是工作区内的目录,因此被链接的目录与其目标一样可列举。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。空路径是 `gateway/bad-request`。 ### 变更流 diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 64e91c7fbf..2c4f6f31b1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -335,7 +335,10 @@ export class WorkspaceFiles extends TypertRemoteService { @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { 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}`, @@ -343,6 +346,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)), diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts index 6cadad6ce3..e2e603dfe4 100644 --- a/packages/api/workspace-files/tests/list.spec.ts +++ b/packages/api/workspace-files/tests/list.spec.ts @@ -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(caps) +/** Create a link to a directory: a Windows junction where junctions exist, a directory symlink elsewhere. */ +const directoryLink = (target: string, link: string): Promise => + 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 () => { From 08b310b5a8e70300150671b54aa95e650ffa24ac Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:58:49 +0800 Subject: [PATCH 002/282] fix(client): paint every keyboard focus ring with the brand colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus rings named eight different colours across 54 client stylesheets, and a control that declared no ring fell back to the browser default, whose colour follows the operating-system accent — so the same build showed orange on one machine and blue on another. Every keyboard focus ring now uses --dsw-alias-brand-primary. A global ui-theme focus.css sheet reaches the controls that declare no ring of their own, and 49 declarations name the same token. Only the colour changed: each declaration keeps its width, style and offset. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 6 ++++ .../2026-09-20-focus-ring-brand-primary.md | 33 +++++++++++++++++++ .../2026-09-20-focus-ring-brand-primary.zh.md | 33 +++++++++++++++++++ .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +-- .../src/client/ChangedFiles.module.css | 2 +- .../src/client/FeedbackDialog.module.css | 4 +-- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +-- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +-- .../src/RiskConfirmation.module.css | 2 +- .../src/markdown/MarkdownText.module.css | 4 +-- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 6 ++-- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/view/Browser.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/client/styles.ts | 2 ++ packages/client/ui-theme/src/styles/focus.css | 8 +++++ .../tests/client-styles.client.spec.ts | 1 + .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 26 +++++++-------- .../src/client/TrajectoryTimeline.module.css | 4 +-- .../src/client/TrajectoryToolbar.module.css | 6 ++-- .../src/client/WorkflowRunPanel.module.css | 6 ++-- 31 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md create mode 100644 packages/client/ui-theme/src/styles/focus.css diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml new file mode 100644 index 0000000000..6707b51680 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e +2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md new file mode 100644 index 0000000000..3c6c041ac0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -0,0 +1,33 @@ +# Agent Note: One keyboard-focus colour for every control + +Status: implemented + +English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) + +## Problem + +Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. + +## Decision + +Every keyboard focus ring uses `--dsw-alias-brand-primary`. + +Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. + +The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. + +## Alternatives considered + +**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. + +**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. + +**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. + +**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. + +## Consequences + +The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. + +`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md new file mode 100644 index 0000000000..541537c54e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -0,0 +1,33 @@ +# Agent Note:所有控件共用一个键盘焦点颜色 + +Status: implemented + +[English](2026-09-20-focus-ring-brand-primary.md) | 中文 + +## 问题 + +客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 + +## 决策 + +所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 + +实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 + +全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 + +## 考虑过的其他做法 + +**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 + +**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 + +**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 + +**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 + +## 影响 + +焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 + +`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index f551605a4a..127856cf99 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index a8ccc480a8..1fb36599ce 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -231,7 +231,7 @@ } .retrySummary:focus-visible { - outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline: 1.5px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 0352ff42d6..b01654cab0 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -139,7 +139,7 @@ inset: 0 0 0 auto; width: 20px; border-radius: inherit; - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; content: ''; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 516ba7e83a..1853f80ac9 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 536bed4f6d..57d19d4130 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -21,7 +21,7 @@ button.header:disabled { cursor: progress; } .list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); } .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; } .row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } +.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } .row:disabled { cursor: progress; } .path { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index af2cacebe7..ee2cc6ee16 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-button-primary-fill); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -79,7 +79,7 @@ .detail:focus { outline: none; border-color: var(--dsw-alias-border-l3); - box-shadow: 0 0 0 1px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 1px var(--dsw-alias-brand-primary); } .submit { diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..c3a406d72e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -34,7 +34,7 @@ } .trigger:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css index 32cc84dd14..00b48a345b 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css @@ -23,7 +23,7 @@ } .trigger:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 793ca4e5d6..43664921b4 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index d63529070b..037803f7f2 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -22,7 +22,7 @@ } :global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } .card:hover { background: var(--plan-card-hover); } -.card:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.card:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index e559fbca0e..d65fcc82d2 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-state-warn-label); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..c4164cd18f 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 9abbc23bdc..f348c5c82c 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -187,7 +187,7 @@ } .stringToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -286,7 +286,7 @@ } .actionButton:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index ae45ba9e00..fcba264b38 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index db318ac6eb..edef6040ae 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -83,7 +83,7 @@ .markdown a:focus-visible, .fileMention:focus-visible { - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Leading link-category glyph inside anchors, file mentions, and @@ -220,7 +220,7 @@ the sheet's link focus color. */ .tableScroll:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Many-column tables (the renderer's `md-table-wide` hook) keep their diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 15b8933fa3..071b2523c5 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -216,7 +216,7 @@ .iconButton:focus-visible, .customizedSummary:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Editing surface: a filled module on the panel, matching the settings diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..510120d2e1 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index bd6006ea56..c0f617575d 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css index 0914d8e6f5..fa1340f2d3 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css +++ b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css @@ -108,7 +108,7 @@ } .address:focus { - outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..c99c729fbb 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-label-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/client/styles.ts b/packages/client/ui-theme/src/client/styles.ts index 2f7c97fd4b..4da44d9696 100644 --- a/packages/client/ui-theme/src/client/styles.ts +++ b/packages/client/ui-theme/src/client/styles.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import base from '../styles/base.css?inline' import cornerShape from '../styles/corner-shape.css?inline' import designPlatform from '../styles/design-platform.css?inline' +import focus from '../styles/focus.css?inline' import scrollbar from '../styles/scrollbar.css?inline' import gradientShadowText from '../styles/gradient-shadow-text.css?inline' import shiki from '../styles/shiki.css?inline' @@ -12,6 +13,7 @@ const STYLES = [ ['base.css', base], ['corner-shape.css', cornerShape], ['design-platform.css', designPlatform], + ['focus.css', focus], ['scrollbar.css', scrollbar], ['gradient-shadow-text.css', gradientShadowText], ['shiki.css', shiki], diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css new file mode 100644 index 0000000000..6837e76b1b --- /dev/null +++ b/packages/client/ui-theme/src/styles/focus.css @@ -0,0 +1,8 @@ +/* One keyboard-focus colour for every control. A control that declares its own + * focus ring sets the colour explicitly, so this sheet reaches only the ones + * that would otherwise paint the browser default — whose colour follows the + * operating-system accent and therefore differs per machine. Declaring + * `outline-color` alone preserves each control's own width, style and offset. */ +:focus-visible { + outline-color: var(--dsw-alias-brand-primary); +} diff --git a/packages/client/ui-theme/tests/client-styles.client.spec.ts b/packages/client/ui-theme/tests/client-styles.client.spec.ts index 9e5cb8a7bd..162d6d042f 100644 --- a/packages/client/ui-theme/tests/client-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/client-styles.client.spec.ts @@ -23,6 +23,7 @@ describe('ui-theme client styles', () => { `${PLUGIN_ID}/base.css`, `${PLUGIN_ID}/corner-shape.css`, `${PLUGIN_ID}/design-platform.css`, + `${PLUGIN_ID}/focus.css`, `${PLUGIN_ID}/scrollbar.css`, `${PLUGIN_ID}/gradient-shadow-text.css`, `${PLUGIN_ID}/shiki.css`, diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index 824bd18fde..d5442160be 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,4 @@ } .path:hover { text-decoration: underline; } -.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; } +.path:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index b07fbe48a2..594c0b3570 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -206,7 +206,7 @@ } .table tbody tr:not([data-collapsed-summary]):focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .table tbody tr[data-selected='true'] { @@ -282,7 +282,7 @@ var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%, var(--dsw-alias-bg-layer-1) ); - box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color); + box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary); } .requestBoundaryControl[data-request-status='error']::before, @@ -734,7 +734,7 @@ } .table tbody tr[data-collapsed-summary]:focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .collapsedTurnContent { @@ -1001,7 +1001,7 @@ .close:focus-visible, .detailTab:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1089,7 +1089,7 @@ .overviewHierarchyNavLink:focus-visible { color: var(--dsw-alias-label-primary); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1147,7 +1147,7 @@ } .timestampToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1215,7 +1215,7 @@ } .overviewTitle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1316,7 +1316,7 @@ } .programAction:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1521,7 +1521,7 @@ } .thinkingToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1810,7 +1810,7 @@ .assistantToolCallButton:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1884,7 +1884,7 @@ .toolCatalogSummary:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index a10fffb9d1..f78dc63472 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -98,7 +98,7 @@ } .earlierHistory:focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary); } .empty { @@ -111,7 +111,7 @@ } .track:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index c286665666..ad7f485f08 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -52,7 +52,7 @@ } .toggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -95,7 +95,7 @@ } .control:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -156,7 +156,7 @@ } .action:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 190f136f70..c7875b946c 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } From 9e7350bb8c91b763e65b868c8888f5fb17f0d651 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:05:51 +0800 Subject: [PATCH 003/282] fix(client): keep a pointer's focus silent until focus navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a control leaves it focused with :focus-visible false, so nothing is drawn. Chromium then reveals the ring the moment any key arrives — a modifier, Escape, a typed character — which is the unexplained focus ring users report: they never navigated there, yet a ring appears under the pointer's last target. Record the input modality on the document element, publish the keyboard modality only for focus navigation (Tab, arrow keys), and let the global focus sheet keep a pointer's focus transparent. Tooltip's pointer modality moves into the same module so tooltips and rings follow one rule. --- packages/client/ui-primitives/src/Tooltip.tsx | 15 +------ .../ui-primitives/src/input-modality.ts | 42 +++++++++++++++++++ .../tests/input-modality.client.spec.ts | 30 +++++++++++++ packages/client/ui-theme/src/styles/focus.css | 9 ++++ 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 packages/client/ui-primitives/src/input-modality.ts create mode 100644 packages/client/ui-primitives/tests/input-modality.client.spec.ts diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 688a411fb9..56e96e3894 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -4,6 +4,7 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' +import { pointerModality } from './input-modality.ts' /** Bubble placement relative to the anchor. */ export type TooltipSide = 'right' | 'bottom' | 'top' @@ -27,18 +28,6 @@ interface AnchorProps { type TooltipLabel = string | (() => string) -// Focus alone cannot reveal how it arrived: a closing menu hands focus back to -// its trigger, and after a mouse selection that programmatic return must not -// raise the trigger's bubble, while keyboard focus must. Capture-phase window -// listeners record the last input modality for every Tooltip. The guard keeps -// the module loadable where no window exists (node-side imports of the -// package's pure helpers). -let pointerModality = false -if (typeof window !== 'undefined') { - window.addEventListener('pointerdown', () => { pointerModality = true }, true) - window.addEventListener('keydown', () => { pointerModality = false }, true) -} - /** * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. @@ -194,7 +183,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, // what the anchor now does (pin → unpin), and the click leaves the // anchor focused, which would otherwise pin the relabelled bubble up. onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() }, - onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() }, + onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {visible && !suppressed && ( diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts new file mode 100644 index 0000000000..039c2c2319 --- /dev/null +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -0,0 +1,42 @@ +/** + * Last-input modality for the whole document. Focus alone cannot reveal how it + * arrived: a closing menu hands focus back to its trigger, and after a mouse + * selection that programmatic return must stay silent, while keyboard focus + * must not. Capture-phase window listeners record the modality, and only focus + * navigation returns the document to the keyboard — a stray modifier or Escape + * is not a reason to reveal the pointer's focus. + * + * The guard keeps the module loadable where no window exists (node-side imports + * of the package's pure helpers). + */ + +/** Modality values published on the document element. */ +export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const + +/** Attribute carrying the current modality, read by the global focus sheet. */ +export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' + +let pointer = false + +/** Navigation keys move focus; every other key must not reveal existing focus. */ +function isFocusNavigation(event: KeyboardEvent): boolean { + return event.key === 'Tab' || event.key.startsWith('Arrow') +} + +function publish(): void { + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) +} + +/** Whether the last input able to own focus came from a pointer. */ +export function pointerModality(): boolean { + return pointer +} + +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('keydown', (event) => { + if (!isFocusNavigation(event)) return + pointer = false + publish() + }, true) +} diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts new file mode 100644 index 0000000000..a36d818cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +/** The document modality that decides whether focus may reveal itself. */ +import { describe, expect, it } from 'vitest' +import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' + +const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } +const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } + +describe('input modality', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + }) + + it('returns to the keyboard modality on focus navigation', () => { + click() + press('Tab') + expect(pointerModality()).toBe(false) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + + click() + press('ArrowDown') + expect(pointerModality()).toBe(false) + }) +}) diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 6837e76b1b..90296e5d78 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -6,3 +6,12 @@ :focus-visible { outline-color: var(--dsw-alias-brand-primary); } + +/* A pointer keeps its focus silent. Clicking a control leaves it focused with no + * visible ring, and a later stray key — a modifier, Escape, a typed character — + * must not be the moment that ring appears; the user never navigated there. + * Only focus navigation publishes the keyboard modality that reveals a ring + * again. Controls that own a ring keep their own colour and stay unaffected. */ +html[data-input-modality='pointer'] :focus-visible { + outline-color: transparent; +} From db9297014667d0074cdc4e71b660baf58a15f1d2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:08:43 +0800 Subject: [PATCH 004/282] fix(client): keep focus rings inside the containers that clip them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb trail and the Miller column scroll, and the column's left edge carries no padding, so an outer ring on their buttons was clipped to a sliver. The TurnNavigator marker sits at the scroller's right edge, which clipped three of its four sides. Ring those inside the element instead — the direction issue #4655 allows — and change no layout value. --- .../ui-chat/src/client/chat/TurnNavigator.module.css | 3 ++- .../src/client/DirectoryBrowser.module.css | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index b01654cab0..29065d039d 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -140,7 +140,8 @@ width: 20px; border-radius: inherit; outline: 1px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; + /* The scroller clips the right edge; keep the marker's ring inside it. */ + outline-offset: -1px; content: ''; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 5e3bbb3775..666293d3da 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -121,6 +121,12 @@ cursor: pointer; } +.crumb:focus-visible { + /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .crumb:hover { color: var(--dsw-alias-label-primary); } @@ -239,6 +245,12 @@ cursor: pointer; } +.row:focus-visible { + /* The column scrolls and its left edge carries no padding; keep the ring inside. */ + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .row:hover { background: var(--dsw-alias-interactive-bg-hover); } From 9a74ba5d08a60ea8ce3cb252ecd07fc1ace55f7b Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:26:14 +0800 Subject: [PATCH 005/282] fix(client): give tooltips and focus rings the modality rule each needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooltip asks what the last input was: after any key the user is working from the keyboard, so a programmatic focus may raise a bubble. A focus ring asks something narrower — Chromium reveals an already-focused control as soon as any key arrives, including a modifier or Escape that never navigated anywhere, which is the ring users report as unexplained. Record both answers and publish the ring's narrower one, so tooltips keep their existing behaviour and rings stay silent for a pointer's focus. --- .../ui-primitives/src/input-modality.ts | 34 ++++++++++++------- .../tests/input-modality.client.spec.ts | 30 ++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts index 039c2c2319..8fdc4a0101 100644 --- a/packages/client/ui-primitives/src/input-modality.ts +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -1,22 +1,26 @@ /** - * Last-input modality for the whole document. Focus alone cannot reveal how it - * arrived: a closing menu hands focus back to its trigger, and after a mouse - * selection that programmatic return must stay silent, while keyboard focus - * must not. Capture-phase window listeners record the modality, and only focus - * navigation returns the document to the keyboard — a stray modifier or Escape - * is not a reason to reveal the pointer's focus. + * Input modality for the whole document, under the two rules its consumers need. * - * The guard keeps the module loadable where no window exists (node-side imports - * of the package's pure helpers). + * A tooltip asks what the last input was: after any key the user is working from + * the keyboard, so a programmatic focus may raise a bubble. A focus ring asks + * something narrower — a pointer's focus must stay invisible — because Chromium + * reveals an already-focused control as soon as any key arrives, including a + * modifier or Escape that never navigated anywhere. Only focus navigation + * returns that second answer to the keyboard. + * + * Both are recorded from capture-phase window listeners. The guard keeps the + * module loadable where no window exists (node-side imports of the package's + * pure helpers). */ /** Modality values published on the document element. */ export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const -/** Attribute carrying the current modality, read by the global focus sheet. */ +/** Attribute carrying whether focus navigation last owned focus. */ export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' let pointer = false +let pointerOwnsFocus = false /** Navigation keys move focus; every other key must not reveal existing focus. */ function isFocusNavigation(event: KeyboardEvent): boolean { @@ -24,7 +28,7 @@ function isFocusNavigation(event: KeyboardEvent): boolean { } function publish(): void { - document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) } /** Whether the last input able to own focus came from a pointer. */ @@ -33,10 +37,16 @@ export function pointerModality(): boolean { } if (typeof window !== 'undefined') { - window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('pointerdown', () => { + pointer = true + pointerOwnsFocus = true + publish() + }, true) window.addEventListener('keydown', (event) => { - if (!isFocusNavigation(event)) return + // Any key returns tooltips to the keyboard; only navigation reveals a ring. pointer = false + if (!isFocusNavigation(event)) return + pointerOwnsFocus = false publish() }, true) } diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts index a36d818cb4..acf032cee7 100644 --- a/packages/client/ui-primitives/tests/input-modality.client.spec.ts +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -1,30 +1,38 @@ // @vitest-environment jsdom -/** The document modality that decides whether focus may reveal itself. */ +/** The two modality answers: what a tooltip reads, and what a focus ring reads. */ import { describe, expect, it } from 'vitest' import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } +const published = (): string | null => document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE) describe('input modality', () => { - it('keeps a pointer focused control silent through keys that do not navigate', () => { + it('returns tooltips to the keyboard on any key', () => { click() expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) - - for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) - expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + for (const key of ['Shift', 'Escape', 'a', 'Enter']) { + click() + press(key) + expect(pointerModality()).toBe(false) + } }) - it('returns to the keyboard modality on focus navigation', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(published()).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(published()).toBe(INPUT_MODALITY.pointer) + }) + + it('returns the ring to the keyboard on focus navigation', () => { click() press('Tab') - expect(pointerModality()).toBe(false) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + expect(published()).toBe(INPUT_MODALITY.keyboard) click() press('ArrowDown') - expect(pointerModality()).toBe(false) + expect(published()).toBe(INPUT_MODALITY.keyboard) }) }) From 5a7b2e6f33fd9ff891f5ddf9da326a6a092cf5af Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:44:58 +0800 Subject: [PATCH 006/282] fix(client): ring the workspace header controls inside their clipping boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action cluster animates its width with overflow hidden, and the search pill clips to its rounded shape, so an outer ring on the add, search and clear controls was cut to a sliver. This package declared no focus style at all — every control fell back to the browser default inside those boxes. Ring the three controls inside the element, the direction issue #4655 allows. --- .../src/client/rows/WorkspaceBrowser.module.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 930f492b94..7340b2d7b8 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -30,6 +30,15 @@ color: var(--dsw-alias-label-secondary); } +/* The action cluster animates its width and the search pill rounds its corners, + * so both clip an outer ring; these controls keep theirs inside instead. */ +.iconButton:focus-visible, +.searchButton:focus-visible, +.clearButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } From 65ba30ab4f4e68c9d0e55da079b11e67c5a73113 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:47:45 +0800 Subject: [PATCH 007/282] refactor(client): name the standard focus-ring width Every ring that used 2px now reads --dsw-focus-ring-width. The dense tables and toolbars keep their 1px ring on purpose: at 2px they visibly outweigh the rows they sit between, so the standard value and the dense exception stay distinct rather than converging on a number nobody measured. --- .../src/client/AgentPresetSection.module.css | 6 +++--- .../client/ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 ++-- .../src/client/DirectoryBrowser.module.css | 4 ++-- .../src/client/FeedbackDialog.module.css | 2 +- .../ui-plan/src/client/PlanModeControl.module.css | 2 +- .../client/ui-plan/src/client/PlanPreview.module.css | 4 ++-- .../src/client/PluginManagerPage.module.css | 10 +++++----- packages/client/ui-primitives/src/Checkbox.module.css | 2 +- .../ui-primitives/src/ConnectionIndicator.module.css | 2 +- packages/client/ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/RiskConfirmation.module.css | 2 +- packages/client/ui-primitives/src/Switch.module.css | 2 +- .../src/client/PluginInventorySettingsTab.module.css | 6 +++--- .../src/client/PluginConfigForm.module.css | 2 +- .../src/client/PluginsSettingsSection.module.css | 2 +- .../ui-settings-plugins/src/client/fields.module.css | 2 +- .../ui-sidebar/src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/styles/focus.css | 7 +++++++ .../src/client/tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 4 ++-- .../src/client/WorkflowRunPanel.module.css | 6 +++--- .../src/client/rows/WorkspaceBrowser.module.css | 2 +- 23 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 91111b0682..2c467892a4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -184,7 +184,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -224,7 +224,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -311,7 +311,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 127856cf99..0ff6257b94 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 1853f80ac9..0417786ef0 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 666293d3da..d4ee1a8231 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -123,7 +123,7 @@ .crumb:focus-visible { /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -247,7 +247,7 @@ .row:focus-visible { /* The column scrolls and its left edge carries no padding; keep the ring inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index ee2cc6ee16..295d3baead 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 43664921b4..bd0bb877c3 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 037803f7f2..2b02db4606 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -22,7 +22,7 @@ } :global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); } .card:hover { background: var(--plan-card-hover); } -.card:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..c4380c9462 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -154,7 +154,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -267,7 +267,7 @@ } .cardOpen:focus-visible::after { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -373,7 +373,7 @@ } .deleteButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -397,7 +397,7 @@ } .partsFilter:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -1067,7 +1067,7 @@ label.registryOption { } .crumb:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Checkbox.module.css b/packages/client/ui-primitives/src/Checkbox.module.css index a78e56c45a..c834dcc72d 100644 --- a/packages/client/ui-primitives/src/Checkbox.module.css +++ b/packages/client/ui-primitives/src/Checkbox.module.css @@ -18,7 +18,7 @@ } .checkbox input:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index d65fcc82d2..a05e3d6ebf 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index c4164cd18f..7492157b09 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index fcba264b38..73ff979683 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Switch.module.css b/packages/client/ui-primitives/src/Switch.module.css index c038331a66..6d8d7f345a 100644 --- a/packages/client/ui-primitives/src/Switch.module.css +++ b/packages/client/ui-primitives/src/Switch.module.css @@ -31,7 +31,7 @@ } .switch:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 510120d2e1..8ed285e745 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css index cf1aade88f..151719c5af 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css @@ -48,6 +48,6 @@ } .save:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index c0f617575d..432ffeb79e 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-settings-plugins/src/client/fields.module.css index 4db120dc7c..c38eff8226 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-settings-plugins/src/client/fields.module.css @@ -60,7 +60,7 @@ } .helpButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c99c729fbb..e2b531df7f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 90296e5d78..4f2c1b3348 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -1,3 +1,10 @@ +/* The standard ring geometry. A component inside a clipping container sets its + * own offset, and the dense tables and toolbars keep a thinner 1px ring, rather + * than bending this value — those exceptions are deliberate, not drift. */ +:root { + --dsw-focus-ring-width: 2px; +} + /* One keyboard-focus colour for every control. A control that declares its own * focus ring sets the colour explicitly, so this sheet reaches only the ones * that would otherwise paint the browser default — whose colour follows the diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index d5442160be..f5a26ef446 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,4 @@ } .path:hover { text-decoration: underline; } -.path:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 594c0b3570..05fddcebb3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index c7875b946c..483abb218d 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 7340b2d7b8..2e692a9fe4 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -35,7 +35,7 @@ .iconButton:focus-visible, .searchButton:focus-visible, .clearButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } From 068c552b1e057aa580ef2875efdaafcdfa2aacfb Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 21 Sep 2026 16:48:44 +0800 Subject: [PATCH 008/282] =?UTF-8?q?fix(session):=20=E5=85=BC=E5=AE=B9=20We?= =?UTF-8?q?bKit=20=E5=8E=9F=E7=94=9F=E6=9E=84=E9=80=A0=E5=99=A8=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/session/tests/json.spec.ts | 31 ++++++++++++++++++- packages/util/values/README.i18n.yaml | 4 +-- packages/util/values/README.md | 2 +- packages/util/values/README.zh.md | 2 +- packages/util/values/src/index.ts | 4 +-- .../workflow/workflow-ptc/src/guest-source.ts | 2 +- 6 files changed, 37 insertions(+), 8 deletions(-) diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts index 31292bc5c4..e213fc9a17 100644 --- a/packages/core/session/tests/json.spec.ts +++ b/packages/core/session/tests/json.spec.ts @@ -1,5 +1,5 @@ import { runInNewContext } from 'node:vm' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' function objectWithForgedIntrinsicPrototype(revoked = false): Record { @@ -64,6 +64,35 @@ describe('snapshotJsonValue', () => { expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype) }) + it('accepts intrinsic containers with WebKit native constructor formatting across realms', () => { + const foreign = runInNewContext('({ Object, Array, value: { nested: [1] } })') as { + Object: ObjectConstructor + Array: ArrayConstructor + value: { nested: number[] } + } + const originalDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, 'toString')! + const originalToString = originalDescriptor.value as (this: unknown) => string + const toString = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) { + if (this === Object || this === foreign.Object) return 'function Object() {\n [native code]\n}' + if (this === Array || this === foreign.Array) return 'function Array() {\n [native code]\n}' + return originalToString.call(this) + }) + try { + for (const value of [{ nested: [1] }, [1], foreign.value, foreign.value.nested]) { + expect(isJsonValue(value)).toBe(true) + const snapshot = snapshotJsonValue(value) + expect(snapshot).toEqual(value) + expect(snapshot).not.toBe(value) + } + const forged = objectWithForgedIntrinsicPrototype() + expect(isJsonValue(forged)).toBe(false) + expect(snapshotJsonValue(forged)).toBeUndefined() + } finally { + toString.mockRestore() + } + expect(Object.getOwnPropertyDescriptor(Function.prototype, 'toString')).toEqual(originalDescriptor) + }) + it('reads each object value and array slot once while materializing', () => { class Exotic { readonly accepted = false diff --git a/packages/util/values/README.i18n.yaml b/packages/util/values/README.i18n.yaml index 5a7772659a..333ec65001 100644 --- a/packages/util/values/README.i18n.yaml +++ b/packages/util/values/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/values/README.md -README.md: a61b1c26992d8d6df6712d178563c99acd6045ca -README.zh.md: c15ad967547bb9e449239eac167ce9e59064cc50 +README.md: e969e806ec06c3ccaaf60128b3c6c3f0d032cac8 +README.zh.md: f723a9b48fd34fc02069386fb43553312f5b0e55 diff --git a/packages/util/values/README.md b/packages/util/values/README.md index a61b1c2699..e969e806ec 100644 --- a/packages/util/values/README.md +++ b/packages/util/values/README.md @@ -55,7 +55,7 @@ Use `assertNever(value, context?)` in the default branch of a closed discriminat
Implementation internals — click to expand -The JSON validator uses an explicit work stack and tracks only the active ancestor chain, so deeply nested values do not consume the JavaScript call stack and repeated non-cyclic references remain valid. Snapshot writes use own data properties, including for names such as `__proto__`. Value operations derive their result only from their arguments; `WeakMapWithValues` stores only instance-owned associations. +The JSON validator uses an explicit work stack and tracks only the active ancestor chain, so deeply nested values do not consume the JavaScript call stack and repeated non-cyclic references remain valid. Intrinsic arrays and plain objects are recognized across JavaScript realms using native constructor representations from the running engine. Snapshot writes use own data properties, including for names such as `__proto__`. Value operations derive their result only from their arguments; `WeakMapWithValues` stores only instance-owned associations. ### Source map diff --git a/packages/util/values/README.zh.md b/packages/util/values/README.zh.md index c15ad96754..f723a9b48f 100644 --- a/packages/util/values/README.zh.md +++ b/packages/util/values/README.zh.md @@ -55,7 +55,7 @@ const snapshot = snapshotJsonValue(input) as JsonValue
实现细节——点击展开 -JSON 校验器使用显式工作栈,并只跟踪当前祖先链,因此深层嵌套值不会消耗 JavaScript 调用栈,重复但无循环的引用仍然有效。快照写入使用自有数据属性,包括 `__proto__` 等名称。值操作的结果只取决于传入参数;`WeakMapWithValues` 仅保存实例自有的关联。 +JSON 校验器使用显式工作栈,并只跟踪当前祖先链,因此深层嵌套值不会消耗 JavaScript 调用栈,重复但无循环的引用仍然有效。内建数组与普通对象通过当前引擎的原生构造器文本表示进行跨 JavaScript realm 识别。快照写入使用自有数据属性,包括 `__proto__` 等名称。值操作的结果只取决于传入参数;`WeakMapWithValues` 仅保存实例自有的关联。 ### 源码地图 diff --git a/packages/util/values/src/index.ts b/packages/util/values/src/index.ts index 908f4f8f0f..2e4b81cc86 100644 --- a/packages/util/values/src/index.ts +++ b/packages/util/values/src/index.ts @@ -14,7 +14,7 @@ export function assertNever(value: never, context?: string): never { throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`) } -/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') const constructor: unknown = descriptor?.value @@ -22,7 +22,7 @@ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): b try { return constructor.name === name && constructor.prototype === prototype - && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === 'Array' ? Array : Object) } catch { return false } diff --git a/packages/workflow/workflow-ptc/src/guest-source.ts b/packages/workflow/workflow-ptc/src/guest-source.ts index a159050889..ed380d0559 100644 --- a/packages/workflow/workflow-ptc/src/guest-source.ts +++ b/packages/workflow/workflow-ptc/src/guest-source.ts @@ -1,4 +1,4 @@ /** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */ /** Self-contained ESM guest transported into the mounted PTC runtime. */ -export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tconst assign = (destination, item) => {\n\t\tif (destination === void 0) return;\n\t\tif (destination.kind === \"root\") root = item;\n\t\telse if (destination.kind === \"array\") destination.target[destination.index] = item;\n\t\telse Object.defineProperty(destination.target, destination.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst tasks = [{\n\t\tkind: \"visit\",\n\t\tvalue,\n\t\t...detach ? { destination: { kind: \"root\" } } : {}\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tancestors.delete(task.source);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"array-item\") {\n\t\t\tif (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.index],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tindex: task.index\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-property\") {\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.key],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tkey: task.key\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst current = task.value;\n\t\tif (current === null) {\n\t\t\tassign(task.destination, null);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"boolean\" || typeof current === \"string\") {\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current !== \"object\") return void 0;\n\t\tif (ancestors.has(current)) return void 0;\n\t\tif (Array.isArray(current)) {\n\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\tconst length = current.length;\n\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\tconst target = detach ? [] : void 0;\n\t\t\tif (target !== void 0) assign(task.destination, target);\n\t\t\tancestors.add(current);\n\t\t\ttasks.push({\n\t\t\t\tkind: \"leave\",\n\t\t\t\tsource: current\n\t\t\t});\n\t\t\tfor (let index = length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"array-item\",\n\t\t\t\tsource: current,\n\t\t\t\tindex,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\tconst keys = enumerableStringKeys(current);\n\t\tif (keys === void 0) return void 0;\n\t\tconst target = detach ? {} : void 0;\n\t\tif (target !== void 0) assign(task.destination, target);\n\t\tancestors.add(current);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tsource: current\n\t\t});\n\t\tfor (let index = keys.length - 1; index >= 0; index--) {\n\t\t\tconst key = keys[index];\n\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\tif (key === void 0) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"object-property\",\n\t\t\t\tsource: current,\n\t\t\t\tkey,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t}\n\t}\n\treturn detach ? root : true;\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" +export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tconst assign = (destination, item) => {\n\t\tif (destination === void 0) return;\n\t\tif (destination.kind === \"root\") root = item;\n\t\telse if (destination.kind === \"array\") destination.target[destination.index] = item;\n\t\telse Object.defineProperty(destination.target, destination.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst tasks = [{\n\t\tkind: \"visit\",\n\t\tvalue,\n\t\t...detach ? { destination: { kind: \"root\" } } : {}\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tancestors.delete(task.source);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"array-item\") {\n\t\t\tif (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.index],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tindex: task.index\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-property\") {\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.key],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tkey: task.key\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst current = task.value;\n\t\tif (current === null) {\n\t\t\tassign(task.destination, null);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"boolean\" || typeof current === \"string\") {\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current !== \"object\") return void 0;\n\t\tif (ancestors.has(current)) return void 0;\n\t\tif (Array.isArray(current)) {\n\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\tconst length = current.length;\n\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\tconst target = detach ? [] : void 0;\n\t\t\tif (target !== void 0) assign(task.destination, target);\n\t\t\tancestors.add(current);\n\t\t\ttasks.push({\n\t\t\t\tkind: \"leave\",\n\t\t\t\tsource: current\n\t\t\t});\n\t\t\tfor (let index = length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"array-item\",\n\t\t\t\tsource: current,\n\t\t\t\tindex,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\tconst keys = enumerableStringKeys(current);\n\t\tif (keys === void 0) return void 0;\n\t\tconst target = detach ? {} : void 0;\n\t\tif (target !== void 0) assign(task.destination, target);\n\t\tancestors.add(current);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tsource: current\n\t\t});\n\t\tfor (let index = keys.length - 1; index >= 0; index--) {\n\t\t\tconst key = keys[index];\n\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\tif (key === void 0) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"object-property\",\n\t\t\t\tsource: current,\n\t\t\t\tkey,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t}\n\t}\n\treturn detach ? root : true;\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" From 9373f74c9c01dbb969b6083363bbe9e6308c8f0b Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 21 Sep 2026 17:25:57 +0800 Subject: [PATCH 009/282] =?UTF-8?q?test(web):=20=E8=A1=A5=E5=85=85=20WebKi?= =?UTF-8?q?t=20=E4=BC=9A=E8=AF=9D=E5=9B=9E=E6=94=BE=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-master.yml | 4 +- .github/workflows/ci.yml | 8 +-- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 2 +- apps/web/tests/README.zh.md | 2 +- apps/web/tests/session-replay-reload.e2e.ts | 79 +++++++++++++++++++++ apps/web/tsconfig.json | 1 + docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- tsconfig.host.json | 1 + 11 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 apps/web/tests/session-replay-reload.e2e.ts diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index be96b7effa..75410ad410 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -165,8 +165,8 @@ jobs: # The persistent VM image owns Playwright's Linux system packages; this # step also proves that browser provisioning remains usable for failover. - - name: Install Playwright Chromium - run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + - name: Install Playwright Chromium and WebKit + run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit - name: Prepare bubblewrap (unrestrict userns) run: bash scripts/prepare-ci-bubblewrap.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c2077159b..9c8d801864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,18 +344,18 @@ jobs: r'https://\1/', source.read_text())) PY - - name: Install Playwright Chromium and hosted dependencies + - name: Install Playwright Chromium, WebKit, and hosted dependencies if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]' # Ephemeral runner images can carry Ubuntu mirrors whose HTTP endpoints are unreachable. run: | sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i -E 's@http://([a-z]{2}\.)?archive\.ubuntu\.com/ubuntu@https://archive.ubuntu.com/ubuntu@g; s@http://security\.ubuntu\.com/ubuntu@https://security.ubuntu.com/ubuntu@g' {} + - pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium + pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit # The persistent VM image owns Playwright's Linux system packages; do # not mutate the shared host with apt on every failover run. - - name: Install Playwright Chromium on the failover VM + - name: Install Playwright Chromium and WebKit on the failover VM if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' - run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium webkit - name: Build and execute the Linux Office runtime run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads" diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 4d920402fa..e929997e45 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2 -README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f +README.md: d3cb7fb424cab3f22b358533603f870282722079 +README.zh.md: dcd9fbe593aaebaad73aea02bfb55569cf225e0c diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 8b9c58ce9e..d3cb7fb424 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +These tests boot the real web composition in-process and drive it with Chromium over real HTTP. [Session replay recovery](session-replay-reload.e2e.ts) also runs in WebKit, sharing the `fresh-round-trip` recording and expected output without rewriting them. It covers active-stream reload, completed-turn reload, and reopening a Session. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 199ae3b561..dcd9fbe593 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 +这些测试在进程内启动真实的 web 组合,并用 Chromium 通过真实 HTTP 驱动它。[会话回放恢复](session-replay-reload.e2e.ts)还在 WebKit 中运行,共用 `fresh-round-trip` 的录制和预期输出,不改写这些文件。该用例覆盖输出中刷新、完成后刷新和重新打开 Session。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。 diff --git a/apps/web/tests/session-replay-reload.e2e.ts b/apps/web/tests/session-replay-reload.e2e.ts new file mode 100644 index 0000000000..14df87afc2 --- /dev/null +++ b/apps/web/tests/session-replay-reload.e2e.ts @@ -0,0 +1,79 @@ +/** Recorded-session recovery through Chromium and WebKit's native JSON validation. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium, webkit } from 'playwright' +import { describe, expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url)) + +describe.skipIf(webSnapshotMode() === 'record')('web session replay', () => { + it.each([{ name: 'Chromium', engine: chromium }, { name: 'WebKit', engine: webkit }])( + '$name restores an active stream, a completed turn, and a reopened session', async ({ engine }) => { + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: 'read-only' }) + onTestFinished(() => scaffold.close()) + const release = Promise.withResolvers() + const reached = Promise.withResolvers() + let paused = false + // Hold the recorded stream until the reloaded page has received its active baseline. + const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) { + for await (const chunk of next()) { + yield chunk + if (!paused && chunk.type === 'text-delta' && chunk.text.trim()) { + paused = true + reached.resolve(chunk.text.trim()) + await release.promise + } + } + }, { prepend: true }) + onTestFinished(() => { release.resolve(undefined); dispose() }) + const browser = await engine.launch() + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + onTestFailed(() => saveFailureShot(page, `session-replay-reload-${engine.name()}`)) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const prompts = fixtureUserPrompts(await readFile(await selectedSessionFixture(FIXTURE), 'utf8')) + expect(prompts).toHaveLength(1) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.fill(prompts[0]!) + await input.press('Enter') + const partial = await reached.promise + const settled = scaffold.whenTurnSettled() + try { + const streaming = page.locator('[data-streaming="true"]').getByText(partial, { exact: true }) + await streaming.waitFor({ timeout: 15_000 }) + const warningStart = tripwire.warnings.length + await page.reload() + await streaming.waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(tripwire.pageErrors).toEqual([]) + } finally { + release.resolve(undefined) + await settled + } + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) + const warningStart = tripwire.warnings.length + await page.reload() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await compareOrRefreshGolden(UI_EXPECTED, + await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), 'replay') + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await page.getByText('Into the Unknown', { exact: true }).waitFor() + await page.getByRole('treeitem').filter({ has: page.getByText('Use the bash tool to', { exact: true }) }).click() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden(UI_EXPECTED, + await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), 'replay') + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, + ) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4c8bac18c8..68a02a371a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -81,6 +81,7 @@ "tests/workspace-recency.e2e.ts", "tests/session-unarchive.e2e.ts", "tests/replay-round-trip.e2e.ts", + "tests/session-replay-reload.e2e.ts", "tests/hmr-live.e2e.ts", "tests/preview-boot.e2e.ts", "tests/seeded-history.e2e.ts", diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index a044baf3b1..032f561493 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 95badfd66ba49151a64dad3f5959a12265449f97 -testing.zh.md: a715fb0a6f285a908857a90e848213f42cc040cf +testing.md: dc4499bc0ba2b2cc95da101a28f43d5a7cfcde2c +testing.zh.md: dceaf62e5c7ef5b4ec775aae8b99eedde3600003 diff --git a/docs/testing.md b/docs/testing.md index 95badfd66b..dc4499bc0b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,7 +12,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`. - **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)). - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. -- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. +- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares Session output in `snapshots/web/` and UI-only output under `apps/web/tests/expected/`; session replay recovery also runs in WebKit. CI uses read-only `DSH_SNAPSHOT=replay`; record/refresh stay local; review every diff ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index a715fb0a6f..dceaf62e5c 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -12,7 +12,7 @@ - **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。 - **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 -- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 +- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出;会话回放恢复还在 WebKit 中运行。CI 使用只读的 `DSH_SNAPSHOT=replay`;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 diff --git a/tsconfig.host.json b/tsconfig.host.json index 50c576de65..aaa4c9a94b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -70,6 +70,7 @@ "apps/web/tests/workspace-recency.e2e.ts", "apps/web/tests/session-unarchive.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", + "apps/web/tests/session-replay-reload.e2e.ts", "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/preview-boot.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", From 6945da14cb0187d7409048eaff5da7110843b9c5 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 17:33:00 +0800 Subject: [PATCH 010/282] feat(web): unify the code-highlight language table and extend it The document Code preview and the Host read card each owned an extension-to-language table that had drifted: the preview table had 13 extensions the read card lacked, and the two returned different language ids for the same suffix. Both now resolve through the new zero-dependency @deepseek-ai/dsh-util-code-language, which also adds common Windows, config, markup, and language extensions. Every added grammar stays in the lazy set; the boot grammars are unchanged. Fixes #4822 --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 2 + docs/module-graph.zh.md | 2 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/package.json | 1 + .../ui-primitives/src/code-highlighting.ts | 49 +------ .../ui-primitives/src/markdown/highlight.ts | 122 ++++++++++++++++-- .../tests/code-block.client.spec.tsx | 16 ++- .../tests/code-highlighting.client.spec.tsx | 38 ++++++ packages/client/ui-primitives/tsconfig.json | 3 + packages/fs/tool-fs/package.json | 3 +- packages/fs/tool-fs/src/read-render.ts | 42 +----- packages/fs/tool-fs/tests/read-render.spec.ts | 23 +++- packages/fs/tool-fs/tests/tools.spec.ts | 4 +- packages/fs/tool-fs/tsconfig.json | 3 + packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/code-language/README.i18n.yaml | 6 + packages/util/code-language/README.md | 50 +++++++ packages/util/code-language/README.zh.md | 50 +++++++ packages/util/code-language/package.json | 35 +++++ packages/util/code-language/src/index.ts | 114 ++++++++++++++++ .../code-language/tests/code-language.spec.ts | 95 ++++++++++++++ packages/util/code-language/tsconfig.json | 10 ++ pnpm-lock.yaml | 12 ++ scripts/doc-standard.spec.ts | 1 + scripts/package-dependency-policy.ts | 1 + scripts/verify-package-dependencies.spec.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 37 files changed, 593 insertions(+), 117 deletions(-) create mode 100644 packages/util/code-language/README.i18n.yaml create mode 100644 packages/util/code-language/README.md create mode 100644 packages/util/code-language/README.zh.md create mode 100644 packages/util/code-language/package.json create mode 100644 packages/util/code-language/src/index.ts create mode 100644 packages/util/code-language/tests/code-language.spec.ts create mode 100644 packages/util/code-language/tsconfig.json diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 805da9758c..720faa3136 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 32faec64e3ece1053430219c6be57a7044308f5a -config-catalog.zh.md: 08a7b368e0332b917de1f627a22415ede4d18f36 +config-catalog.md: 722d4afebf216915cd70f838c2e45e3739722f2a +config-catalog.zh.md: 1d7e2b3ac62c9a3b113b80bb4490921dfaf473de diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 32faec64e3..722d4afebf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4133,6 +4133,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-util-code-language` ([`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts)) - `@deepseek-ai/dsh-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) - `@deepseek-ai/dsh-util-time` ([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-values` ([`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 08a7b368e0..1d7e2b3ac6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4136,6 +4136,7 @@ export interface Config { - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts)) - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts)) - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts)) +- `@deepseek-ai/dsh-util-code-language`([`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts)) - `@deepseek-ai/dsh-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts)) - `@deepseek-ai/dsh-util-time`([`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts)) - `@deepseek-ai/dsh-util-values`([`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1cea8df504..47cb21499b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 2e22c198c29bfba5eaa1a548af3bb7a9f190d0c8 -module-graph.zh.md: ce5c445c01b197c260d41af1d991b10fe799873a +module-graph.md: fddd44f812c4b7b9a6483765788dd8e2839dad35 +module-graph.zh.md: 23cf2de8c34c8a6d68c45e474be0ab3760677290 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2e22c198c2..fddd44f812 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -20,6 +20,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_timeout["timeout"] + pkg_util_code_language["util-code-language"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] pkg_util_values["util-values"] @@ -1348,6 +1349,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-code-language`](../packages/util/code-language) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | | [`util-values`](../packages/util/values) | `util` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ce5c445c01..23cf2de8c3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -22,6 +22,7 @@ flowchart TD pkg_output_retention["output-retention"] pkg_package_manifest["package-manifest"] pkg_timeout["timeout"] + pkg_util_code_language["util-code-language"] pkg_util_crypto["util-crypto"] pkg_util_time["util-time"] pkg_util_values["util-values"] @@ -1350,6 +1351,7 @@ flowchart TD | [`output-retention`](../packages/util/output-retention) | `util` | — | | [`package-manifest`](../packages/util/package-manifest) | `util` | — | | [`timeout`](../packages/util/timeout) | `util` | — | +| [`util-code-language`](../packages/util/code-language) | `util` | — | | [`util-crypto`](../packages/util/crypto) | `util` | — | | [`util-time`](../packages/util/time) | `util` | — | | [`util-values`](../packages/util/values) | `util` | — | diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index be151e0ba5..b2db491525 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7bba90f3e263c3e073c5478feb3d7dd4777d04a7 -README.zh.md: ceee6cb649672e5e47dc8325d1f277fa75953823 +README.md: 8a888bbd07c300e43383f20e223437df28154684 +README.zh.md: 3b34712eaa77e1fde30071c2379255df4d1c8647 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7bba90f3e2..8a888bbd07 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -63,7 +63,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | -| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | +| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card. | Four pairs are easy to confuse: diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index ceee6cb649..3b34712eaa 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -63,7 +63,7 @@ kind: "package-library" | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | -| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | +| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片背后的同一张扩展名表。 | 有四组容易混淆: diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index bb7b2de32e..5187e57a1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -27,6 +27,7 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-util-code-language": "workspace:^", "react": "^18.2.0", "clsx": "^2.0.0", "diff": "^9.0.0", diff --git a/packages/client/ui-primitives/src/code-highlighting.ts b/packages/client/ui-primitives/src/code-highlighting.ts index 6cf4489d9a..415fe24564 100644 --- a/packages/client/ui-primitives/src/code-highlighting.ts +++ b/packages/client/ui-primitives/src/code-highlighting.ts @@ -4,51 +4,10 @@ import { grammarLoadCount, highlightLines, subscribeGrammarLoaded, type HighlightSpan, } from './markdown/highlight.ts' -const LANGUAGE_EXTENSIONS: Readonly> = { - typescript: ['ts', 'tsx', 'mts', 'cts'], - javascript: ['js', 'jsx', 'mjs', 'cjs'], - shellscript: ['sh', 'bash', 'zsh'], - json: ['json', 'jsonc', 'jsonl', 'ndjson'], - python: ['py', 'pyw', 'pyi'], - ruby: ['rb', 'rake', 'gemspec'], - go: ['go'], - rust: ['rs'], - java: ['java'], - c: ['c', 'h'], - cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], - csharp: ['cs'], - kotlin: ['kt', 'kts'], - swift: ['swift'], - php: ['php'], - yaml: ['yaml', 'yml'], - toml: ['toml'], - ini: ['ini'], - markdown: ['md', 'markdown'], - mdx: ['mdx'], - html: ['html', 'htm', 'xhtml'], - css: ['css'], - scss: ['scss'], - less: ['less'], - sql: ['sql'], - xml: ['xml', 'xsd', 'xsl', 'xslt'], - lua: ['lua'], -} - -const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) - .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) - -/** Recognized filename suffixes whose source can use the shared syntax highlighter. */ -export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] - -/** - * Select the shared syntax highlighter's grammar from a filename. - * @param path - decoded source filename or path. - * @returns a supported grammar hint, or `undefined` for other suffixes. - */ -export function languageForPath(path: string): string | undefined { - const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase() - return extension === undefined ? undefined : LANGUAGES.get(extension) -} +// The single extension table is owned by `@deepseek-ai/dsh-util-code-language`, +// shared with the Host read card; re-export it so Client callers keep importing +// the language selector and the preview registry's suffix list from this package. +export { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' /** Highlight one source fragment into one token list per line. */ export type CodeHighlighter = (code: string) => HighlightSpan[][] | undefined diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 889ccf9325..989d22a711 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -6,13 +6,12 @@ * dark blocks), never here — the repo's tokens-only styling rule. * * Only the three markdown-fence and `run_code` grammars (TypeScript, shell, - * JSON) load into the singleton at boot — the set every session renders. The - * read card's wider extension set (the file-extension language hints the read - * tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml, - * markup, …) is imported lazily and registered the first time such a language - * is requested, so a session that never opens a read card in one of those - * languages pays neither the ~1.6 MB of grammar modules nor their synchronous - * init. The first render of a lazy language falls back to plain text while its + * JSON) load into the singleton at boot — the set every session renders. Every + * other language in the shared extension table + * (`@deepseek-ai/dsh-util-code-language`: python, rust, yaml, markup, …) is + * imported lazily and registered the first time such a language is requested, + * so a session that never opens a code surface in one of those languages pays + * neither the grammar modules nor their synchronous init. The first render of a lazy language falls back to plain text while its * grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render * with highlighting. An unknown or absent language falls back to plain text (no * highlighting, still monospace) — never an error. @@ -42,8 +41,8 @@ type LangModule = { default: typeof langTs } const LANGS = [langTs, langBash, langJson] /** - * The read card's extension grammars, each behind a dynamic import so its - * module stays out of the boot chunk until a read of that language renders. + * The non-boot extension grammars, each behind a dynamic import so its module + * stays out of the boot chunk until a code surface renders that language. * Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to. * `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader * hands the whole array to `loadLanguageSync`, which registers each entry @@ -74,6 +73,40 @@ const LAZY_GRAMMARS = new Map Promise>([ ['sql', () => import('@shikijs/langs/sql')], ['xml', () => import('@shikijs/langs/xml')], ['lua', () => import('@shikijs/langs/lua')], + ['bat', () => import('@shikijs/langs/bat')], + ['powershell', () => import('@shikijs/langs/powershell')], + ['fish', () => import('@shikijs/langs/fish')], + ['dotenv', () => import('@shikijs/langs/dotenv')], + ['log', () => import('@shikijs/langs/log')], + ['csv', () => import('@shikijs/langs/csv')], + ['diff', () => import('@shikijs/langs/diff')], + ['http', () => import('@shikijs/langs/http')], + ['rst', () => import('@shikijs/langs/rst')], + ['latex', () => import('@shikijs/langs/latex')], + ['bibtex', () => import('@shikijs/langs/bibtex')], + ['asciidoc', () => import('@shikijs/langs/asciidoc')], + ['r', () => import('@shikijs/langs/r')], + ['julia', () => import('@shikijs/langs/julia')], + ['dart', () => import('@shikijs/langs/dart')], + ['scala', () => import('@shikijs/langs/scala')], + ['clojure', () => import('@shikijs/langs/clojure')], + ['erlang', () => import('@shikijs/langs/erlang')], + ['elixir', () => import('@shikijs/langs/elixir')], + ['haskell', () => import('@shikijs/langs/haskell')], + ['fsharp', () => import('@shikijs/langs/fsharp')], + ['vb', () => import('@shikijs/langs/vb')], + ['perl', () => import('@shikijs/langs/perl')], + ['verilog', () => import('@shikijs/langs/verilog')], + ['system-verilog', () => import('@shikijs/langs/system-verilog')], + ['graphql', () => import('@shikijs/langs/graphql')], + ['proto', () => import('@shikijs/langs/proto')], + ['hcl', () => import('@shikijs/langs/hcl')], + ['nix', () => import('@shikijs/langs/nix')], + ['vue', () => import('@shikijs/langs/vue')], + ['svelte', () => import('@shikijs/langs/svelte')], + ['make', () => import('@shikijs/langs/make')], + ['cmake', () => import('@shikijs/langs/cmake')], + ['groovy', () => import('@shikijs/langs/groovy')], ]) /** @@ -81,9 +114,9 @@ const LAZY_GRAMMARS = new Map Promise>([ * plain. A Map, not an object: fence info strings are assistant-authored, so * a label like `constructor` or `__proto__` must miss instead of resolving an * inherited property and crashing the renderer inside shiki. Keys cover both - * the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids - * the read tool's `langFromPath` emits, so both callers resolve the same - * grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for + * the markdown-fence aliases `CodeBlock` uses and the file-extension language + * ids `@deepseek-ai/dsh-util-code-language` resolves, so both callers resolve the + * same grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for * the JSX/TSX approximation). A value not in {@link LANGS} names a * {@link LAZY_GRAMMARS} entry loaded on first use. */ @@ -130,6 +163,71 @@ const LANG_ALIASES = new Map([ ['sql', 'sql'], ['xml', 'xml'], ['lua', 'lua'], + ['bat', 'bat'], + ['batch', 'bat'], + ['powershell', 'powershell'], + ['ps1', 'powershell'], + ['ps', 'powershell'], + ['fish', 'fish'], + ['properties', 'ini'], + ['dotenv', 'dotenv'], + ['env', 'dotenv'], + ['log', 'log'], + ['csv', 'csv'], + ['diff', 'diff'], + ['patch', 'diff'], + ['http', 'http'], + ['rst', 'rst'], + ['latex', 'latex'], + ['tex', 'latex'], + ['bibtex', 'bibtex'], + ['bib', 'bibtex'], + ['asciidoc', 'asciidoc'], + ['adoc', 'asciidoc'], + ['r', 'r'], + ['julia', 'julia'], + ['jl', 'julia'], + ['dart', 'dart'], + ['scala', 'scala'], + ['clojure', 'clojure'], + ['clj', 'clojure'], + ['erlang', 'erlang'], + ['erl', 'erlang'], + ['elixir', 'elixir'], + ['ex', 'elixir'], + ['exs', 'elixir'], + ['haskell', 'haskell'], + ['hs', 'haskell'], + ['fsharp', 'fsharp'], + ['fs', 'fsharp'], + ['fsi', 'fsharp'], + ['fsx', 'fsharp'], + ['vb', 'vb'], + ['vbnet', 'vb'], + ['perl', 'perl'], + ['pl', 'perl'], + ['pm', 'perl'], + ['verilog', 'verilog'], + ['system-verilog', 'system-verilog'], + ['systemverilog', 'system-verilog'], + ['sv', 'system-verilog'], + ['svh', 'system-verilog'], + ['graphql', 'graphql'], + ['gql', 'graphql'], + ['proto', 'proto'], + ['protobuf', 'proto'], + ['hcl', 'hcl'], + ['tf', 'hcl'], + ['tfvars', 'hcl'], + ['nix', 'nix'], + ['vue', 'vue'], + ['svelte', 'svelte'], + ['make', 'make'], + ['makefile', 'make'], + ['mk', 'make'], + ['cmake', 'cmake'], + ['groovy', 'groovy'], + ['gradle', 'groovy'], ]) /** diff --git a/packages/client/ui-primitives/tests/code-block.client.spec.tsx b/packages/client/ui-primitives/tests/code-block.client.spec.tsx index 5e6475297c..9ea456df4d 100644 --- a/packages/client/ui-primitives/tests/code-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.client.spec.tsx @@ -33,16 +33,21 @@ describe('highlightToHtml', () => { expect(highlightToHtml('x', undefined)).toBeUndefined() }) - // Every read-tool language hint whose grammar loads lazily (the boot set — - // ts/js/shell/sh/json — is covered above). Touching each one drives its own - // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised. + // Every language whose grammar loads lazily (the boot set — ts/js/shell/sh/json + // — is covered above). Touching each one drives its own dynamic import thunk, so + // the whole LAZY_GRAMMARS table is exercised. const LAZY_ALIASES = [ 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php', 'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql', 'xml', 'lua', + 'fish', 'dotenv', 'log', 'csv', 'diff', 'http', 'rst', 'latex', 'bibtex', + 'asciidoc', 'bat', 'powershell', 'r', 'julia', 'dart', 'scala', 'clojure', + 'erlang', 'elixir', 'haskell', 'fsharp', 'vb', 'perl', 'verilog', + 'system-verilog', 'graphql', 'proto', 'hcl', 'nix', 'vue', 'svelte', 'make', + 'cmake', 'groovy', ] - it('lazily loads every read-card grammar: plain first, highlighted after load', async () => { + it('lazily loads every extension grammar: plain first, highlighted after load', async () => { const registered = Promise.withResolvers() // Registration notifications, not a private polling deadline, establish readiness. const stop = subscribeGrammarLoaded(() => { @@ -55,7 +60,8 @@ describe('highlightToHtml', () => { } finally { stop() } - }) + // 57 dynamic grammars (some with large embedded sub-grammars) exceed the default. + }, 120_000) }) describe('CodeBlock', () => { diff --git a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx index 04a54eaceb..c8fdde9199 100644 --- a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, renderHook, waitFor } from '@testing-library/react' +import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language' import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts' import { supportsHighlighting } from '../src/markdown/highlight.ts' @@ -24,6 +25,32 @@ describe('code highlighting', () => { expect(languageForPath(path)).toBe(language) }) + it.each([ + ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], + ['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'], + ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], + ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'], + ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], + ['analysis.r', 'r'], ['model.jl', 'julia'], ['main.dart', 'dart'], ['Main.scala', 'scala'], + ['core.clj', 'clojure'], ['ui.cljs', 'clojure'], ['data.edn', 'clojure'], ['app.erl', 'erlang'], + ['app.hrl', 'erlang'], ['app.ex', 'elixir'], ['app.exs', 'elixir'], ['Main.hs', 'haskell'], + ['Types.fs', 'fsharp'], ['Types.fsi', 'fsharp'], ['Form.vb', 'vb'], ['script.pl', 'perl'], + ['Module.pm', 'perl'], ['top.v', 'verilog'], ['top.sv', 'system-verilog'], + ['defs.svh', 'system-verilog'], ['schema.graphql', 'graphql'], ['query.gql', 'graphql'], + ['message.proto', 'proto'], ['main.tf', 'hcl'], ['terraform.tfvars', 'hcl'], ['stack.hcl', 'hcl'], + ['flake.nix', 'nix'], ['App.vue', 'vue'], ['App.svelte', 'svelte'], ['build.mk', 'make'], + ['CMakeLists.cmake', 'cmake'], ['build.gradle', 'groovy'], + ])('uses the extended %s grammar hint %s', (path, language) => { + expect(languageForPath(path)).toBe(language) + }) + + it('resolves through the one shared extension table, never a local copy', () => { + // A second table re-introduced here fails this identity assertion before it + // can drift from the read card's file-extension hints. + expect(languageForPath).toBe(sharedLanguageForPath) + }) + it('keeps every registered suffix highlightable by the shared primitive', () => { expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { @@ -50,4 +77,15 @@ describe('code highlighting', () => { await waitFor(() => { expect(lazy.result.current).not.toBe(loading) }) expect(lazy.result.current('local answer = 42')).not.toBeUndefined() }) + + it.each(['powershell', 'system-verilog', 'bat'])('loads the %s grammar through the lazy path', async (language) => { + const hook = renderHook(() => useCodeHighlighter(language)) + const initial = hook.result.current + // Invoking the plain fallback starts the dynamic import; the callback + // identity changes once the grammar registers and notifies subscribers. + if (initial('sample') === undefined) { + await waitFor(() => { expect(hook.result.current).not.toBe(initial) }) + } + expect(hook.result.current('sample')).not.toBeUndefined() + }) }) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index 201a8a711e..edee79e624 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -13,6 +13,9 @@ "references": [ { "path": "../store" + }, + { + "path": "../../util/code-language" } ] } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 4f0e9a11a6..7d6e8e5d79 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -28,7 +28,8 @@ "license": "MIT", "dependencies": { "diff": "^9.0.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-util-code-language": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 19b6c0b1e7..7862a4366f 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -170,44 +170,14 @@ ${body} } /** - * Lowercased file-extension to syntax-highlighting language hint. Keys are the - * extension without its dot; a UI treats an absent key as plain text. The map is - * intentionally small — common source, config, and markup extensions a - * line-numbered code view benefits from highlighting — not an exhaustive registry. - */ -const LANG_BY_EXTENSION: Readonly> = { - ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', - js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', - json: 'json', jsonc: 'json', - py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', - c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', - cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', - sh: 'sh', bash: 'sh', zsh: 'sh', - yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', - md: 'md', markdown: 'md', mdx: 'mdx', - html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', - sql: 'sql', xml: 'xml', lua: 'lua', -} - -/** - * Derive a syntax-highlighting language hint from a read path's file extension. - * Pure and case-insensitive on the extension; a dotfile with no extension - * (`.gitignore`) and an unknown extension both yield `undefined`. + * Derive a syntax-highlighting language id from a read path's file extension via + * the extension table shared with the Client code surfaces; see + * `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both + * separators, a leading dot as the extension separator, and prototype-key safety). * @param path - the model-facing path the read reported. - * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none. + * @returns the language id, or `undefined` when the extension maps to none. */ -export function langFromPath(path: string): string | undefined { - const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) - const dot = base.lastIndexOf('.') - // A leading dot is a dotfile (no extension), not an empty extension. - if (dot <= 0) return undefined - const ext = base.slice(dot + 1).toLowerCase() - // Own-property check only: a filename whose extension is an Object.prototype - // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the - // inherited member — otherwise a function would reach `lang` and fail the - // tool-output JSON validation. - return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined -} +export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' /** * The `read` tool's private `tool/result` `meta` payload: the structured diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index cc03a0c8f9..2e364b8e74 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it } from 'vitest' +import { languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -118,19 +119,29 @@ describe('buildWindow', () => { }) describe('langFromPath', () => { - it('maps a known extension to its language hint, case-insensitively', () => { - expect(langFromPath('src/a.ts')).toBe('ts') - expect(langFromPath('src/a.TSX')).toBe('tsx') - expect(langFromPath('/abs/module.mjs')).toBe('js') + it('resolves through the one shared extension table, never a local copy', () => { + // The read card and the Client code surfaces must agree on every path; a + // local table re-introduced on either side makes one of these identity + // assertions fail before the two can drift in language hints. + expect(langFromPath).toBe(languageForPath) + for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) { + expect(langFromPath(path), path).toBe(languageForPath(path)) + } + }) + + it('maps a known extension to its canonical language id, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('typescript') + expect(langFromPath('src/a.TSX')).toBe('typescript') + expect(langFromPath('/abs/module.mjs')).toBe('javascript') expect(langFromPath('conf.yml')).toBe('yaml') - expect(langFromPath('README.md')).toBe('md') + expect(langFromPath('README.md')).toBe('markdown') }) it('reads the extension after the last path segment and last dot', () => { expect(langFromPath('a.py.bak')).toBeUndefined() expect(langFromPath('archive.tar.gz')).toBeUndefined() expect(langFromPath('/dir.py/plain')).toBeUndefined() - expect(langFromPath('C:\\src\\main.rs')).toBe('rs') + expect(langFromPath('C:\\src\\main.rs')).toBe('rust') }) it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 28613a2920..ac70df7015 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,7 +352,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'ts', + lang: 'typescript', }) const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) expect(view).toEqual({ @@ -361,7 +361,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'ts', + lang: 'typescript', content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], }) }) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 5ae5f31375..ca6e6e073a 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -41,6 +41,9 @@ }, { "path": "../../attachment/attachment" + }, + { + "path": "../../util/code-language" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 2499f0a247..7e5618085f 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md -README.md: a51f8304e621e1c4fba79e5492c8eddc4d786a8e -README.zh.md: d2b8734543a7332b8a18fd9202c6fcf3b9736af9 +README.md: bd89353f66f8e852f531b393135aff76bbd612ca +README.zh.md: 52807d86206317a31fe5196825bf8a0ac5402d62 diff --git a/packages/util/README.md b/packages/util/README.md index a51f8304e6..bd89353f66 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -39,6 +39,7 @@ Each package provides one primitive; open a package page for how to use it. | [`lazy-require/`](lazy-require/README.md) | Loads CommonJS-compatible Host dependencies on first use with caller-relative resolution | | [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string | | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | +| [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language Code preview, diff review, and the read card render | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | | [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index d2b8734543..52807d8620 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -39,6 +39,7 @@ kind: "package-group" | [`lazy-require/`](lazy-require/README.zh.md) | 在首次使用时按调用方位置加载兼容 CommonJS 的 Host 依赖 | | [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 | | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | +| [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片渲染的语法高亮语言 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | | [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 | diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml new file mode 100644 index 0000000000..e9789fe208 --- /dev/null +++ b/packages/util/code-language/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/util/code-language/README.md +README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d +README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md new file mode 100644 index 0000000000..a65c28cc11 --- /dev/null +++ b/packages/util/code-language/README.md @@ -0,0 +1,50 @@ +--- +description: "Single file-extension to syntax-highlighting language table shared by the Client code surfaces and the Host read card." +kind: "package-library" +--- + +# dsh-util-code-language + +English | [中文](README.zh.md) + +## Summary + +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter. + +## Table of Contents + +- [Language selection](#language-selection) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Language selection + +The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. + +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. + +----- + +## Known Limitations and Deferred Work + + + +- **Extension-only matching** — `languageForPath` reads a suffix, so names recognized without one (`Dockerfile`, `Makefile`, `.gitignore`, `.editorconfig`) stay unlisted. Filename rules are deferred. +- **No content sniffing** — a file with an absent or unknown extension stays plain text even when its bytes are unambiguous; the table never inspects content. +- **Curated, not exhaustive** — the table is smaller than Shiki's grammar catalog and GitHub linguist; adding a language means adding both the extension entry and the Client grammar registration. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. This utility owns no mutable runtime relationship. diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md new file mode 100644 index 0000000000..cc15751853 --- /dev/null +++ b/packages/util/code-language/README.zh.md @@ -0,0 +1,50 @@ +--- +description: "Client 代码界面与 Host read 卡片共享的唯一「扩展名 → 语法高亮语言」表。" +kind: "package-library" +--- + +# dsh-util-code-language + +[English](README.md) | 中文 + +## 概述 + +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 + +## 目录 + +- [语言选择](#language-selection) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 语言选择 + +表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 + +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 + +----- + +## 已知限制与暂缓事项 + + + +- **只匹配扩展名**——`languageForPath` 只看后缀,因此 `Dockerfile`、`Makefile`、`.gitignore`、`.editorconfig` 这类靠文件名识别的名称仍不表。文件名规则暂缓。 +- **不做内容嗅探**——扩展名缺失或未知时,即使字节含义明确也按纯文本处理;本表从不读取内容。 +- **精选而非穷举**——本表小于 Shiki 的 grammar 目录与 GitHub linguist;新增语言意味着同时添加扩展名条目与 Client grammar 注册。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生入口。这个工具不持有可变运行时关系。 diff --git a/packages/util/code-language/package.json b/packages/util/code-language/package.json new file mode 100644 index 0000000000..640a5eb7fa --- /dev/null +++ b/packages/util/code-language/package.json @@ -0,0 +1,35 @@ +{ + "name": "@deepseek-ai/dsh-util-code-language", + "description": "Single file-extension to syntax-highlighting language table shared by Client code surfaces and the Host read card", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/code-language" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts new file mode 100644 index 0000000000..09aac2143f --- /dev/null +++ b/packages/util/code-language/src/index.ts @@ -0,0 +1,114 @@ +/** + * The single file-extension to syntax-highlighting language table shared by every + * code surface: the Client's document Code preview and diff review, and the Host + * read tool's persisted `lang` hint. Language ids are the grammar ids the Client + * highlighter's alias table resolves, so a language returned here reaches + * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or + * one naming a language the highlighter does not register, renders as plain text. + * @module @deepseek-ai/dsh-util-code-language + */ + +/** + * Recognized extensions per canonical language id. Values are lowercase + * extensions without the dot; the id names the grammar rather than a display + * label. The set covers common source, config, script, data, and markup + * extensions a line-numbered code view or preview benefits from highlighting; it + * is deliberately not an exhaustive linguist registry. Extensions whose Shiki + * grammar does not exist map to the nearest available grammar + * (`properties` to `ini`, whose registration carries the `properties` alias) or + * stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`, + * `lock`) intentionally stay unlisted. + */ +const LANGUAGE_EXTENSIONS: Readonly> = { + typescript: ['ts', 'tsx', 'mts', 'cts'], + javascript: ['js', 'jsx', 'mjs', 'cjs'], + shellscript: ['sh', 'bash', 'zsh'], + fish: ['fish'], + json: ['json', 'jsonc', 'jsonl', 'ndjson', 'ipynb'], + python: ['py', 'pyw', 'pyi'], + ruby: ['rb', 'rake', 'gemspec'], + go: ['go'], + rust: ['rs'], + java: ['java'], + c: ['c', 'h'], + cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'], + csharp: ['cs'], + kotlin: ['kt', 'kts'], + swift: ['swift'], + php: ['php'], + yaml: ['yaml', 'yml'], + toml: ['toml'], + ini: ['ini', 'conf', 'cfg', 'properties'], + dotenv: ['env'], + log: ['log'], + csv: ['csv'], + diff: ['diff', 'patch'], + http: ['http'], + markdown: ['md', 'markdown'], + mdx: ['mdx'], + rst: ['rst'], + latex: ['tex', 'sty', 'cls'], + bibtex: ['bib'], + asciidoc: ['adoc'], + html: ['html', 'htm', 'xhtml'], + css: ['css'], + scss: ['scss'], + less: ['less'], + sql: ['sql'], + xml: ['xml', 'xsd', 'xsl', 'xslt', 'plist', 'svg'], + lua: ['lua'], + bat: ['bat', 'cmd'], + powershell: ['ps1', 'psm1', 'psd1'], + r: ['r'], + julia: ['jl'], + dart: ['dart'], + scala: ['scala'], + clojure: ['clj', 'cljs', 'edn'], + erlang: ['erl', 'hrl'], + elixir: ['ex', 'exs'], + haskell: ['hs'], + fsharp: ['fs', 'fsi', 'fsx'], + vb: ['vb'], + perl: ['pl', 'pm'], + verilog: ['v'], + 'system-verilog': ['sv', 'svh'], + graphql: ['graphql', 'gql'], + proto: ['proto'], + hcl: ['tf', 'tfvars', 'hcl'], + nix: ['nix'], + vue: ['vue'], + svelte: ['svelte'], + make: ['makefile', 'mk'], + cmake: ['cmake'], + groovy: ['gradle'], +} + +/** + * Lowercase extension to canonical language id. A Map, not an object: a filename + * whose extension is an `Object.prototype` key (`foo.constructor`, + * `foo.__proto__`) must miss instead of resolving the inherited member, which + * would otherwise reach callers as a non-string language value. + */ +const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) + .flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const))) + +/** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */ +export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] + +/** + * Derive the syntax-highlighting language from a filename or path, case + * insensitively. The extension is the text after the last dot of the final path + * segment; a leading dot is still the separator, so `.env` resolves to `dotenv` + * while an unlisted dotfile (`.gitignore`) and an extensionless name return + * `undefined`. Both path separators are recognized, so + * `C:\\dir\\main.PS1` resolves like `dir/main.ps1`. + * @param path - decoded filename or path. + * @returns the canonical language id, or `undefined` for an unrecognized or absent suffix. + */ +export function languageForPath(path: string): string | undefined { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + const base = path.slice(slash + 1) + const dot = base.lastIndexOf('.') + if (dot < 0) return undefined + return LANGUAGES.get(base.slice(dot + 1).toLowerCase()) +} diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts new file mode 100644 index 0000000000..0ede43e840 --- /dev/null +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts' + +describe('languageForPath', () => { + it.each([ + // Windows-facing script and shell extensions. + ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], + ['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'], + // Config, data, and text extensions. + ['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'], + ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], + ['notebook.ipynb', 'json'], + // Documentation and markup extensions. + ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], + ['doc.cls', 'latex'], ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], + ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], + // Language extensions. + ['analysis.r', 'r'], ['model.jl', 'julia'], ['main.dart', 'dart'], + ['Main.scala', 'scala'], ['core.clj', 'clojure'], ['ui.cljs', 'clojure'], + ['data.edn', 'clojure'], ['app.erl', 'erlang'], ['app.hrl', 'erlang'], + ['app.ex', 'elixir'], ['app.exs', 'elixir'], ['Main.hs', 'haskell'], + ['Types.fs', 'fsharp'], ['Types.fsi', 'fsharp'], ['script.fsx', 'fsharp'], + ['Form.vb', 'vb'], ['script.pl', 'perl'], ['Module.pm', 'perl'], + ['top.v', 'verilog'], ['top.sv', 'system-verilog'], ['defs.svh', 'system-verilog'], + ['schema.graphql', 'graphql'], ['query.gql', 'graphql'], ['message.proto', 'proto'], + ['main.tf', 'hcl'], ['terraform.tfvars', 'hcl'], ['stack.hcl', 'hcl'], + ['flake.nix', 'nix'], ['App.vue', 'vue'], ['App.svelte', 'svelte'], + ['Makefile', undefined], ['build.mk', 'make'], ['CMakeLists.cmake', 'cmake'], + ['build.gradle', 'groovy'], + ])('resolves %s to %s', (path, language) => { + expect(languageForPath(path)).toBe(language) + }) + + it('keeps the extensions the two former tables already agreed on', () => { + expect(languageForPath('src/a.ts')).toBe('typescript') + expect(languageForPath('src/a.TSX')).toBe('typescript') + expect(languageForPath('/abs/module.mjs')).toBe('javascript') + expect(languageForPath('conf.yml')).toBe('yaml') + expect(languageForPath('README.md')).toBe('markdown') + expect(languageForPath('C:\\src\\main.rs')).toBe('rust') + }) + + it('resolves every extension that only the preview table knew, including the new ones', () => { + for (const extension of ['jsonl', 'ndjson', 'pyw', 'pyi', 'rake', 'gemspec', 'hh', 'hxx', 'kts', 'xhtml', 'xsd', 'xsl', 'xslt']) { + expect(languageForPath(`file.${extension}`), extension).toBeDefined() + } + }) + + it('is case-insensitive and accepts Windows separators', () => { + expect(languageForPath('C:\\path\\X.PS1')).toBe('powershell') + expect(languageForPath('C:\\Proj\\Build.CMD')).toBe('bat') + expect(languageForPath('dir\\sub\\Main.SCALA')).toBe('scala') + }) + + it('reads the suffix after the last path segment and last dot', () => { + expect(languageForPath('a.py.bak')).toBeUndefined() + expect(languageForPath('archive.tar.gz')).toBeUndefined() + expect(languageForPath('/dir.py/plain')).toBeUndefined() + expect(languageForPath('dir.ts/README')).toBeUndefined() + }) + + it('returns undefined for a dotfile, an extensionless name, a trailing dot, and an unknown suffix', () => { + expect(languageForPath('.gitignore')).toBeUndefined() + expect(languageForPath('/etc/hosts')).toBeUndefined() + expect(languageForPath('trailingdot.')).toBeUndefined() + expect(languageForPath('data.unknownext')).toBeUndefined() + }) + + it('returns undefined for a filename whose extension is an Object.prototype key', () => { + expect(languageForPath('foo.constructor')).toBeUndefined() + expect(languageForPath('foo.__proto__')).toBeUndefined() + expect(languageForPath('foo.toString')).toBeUndefined() + expect(languageForPath('foo.hasOwnProperty')).toBeUndefined() + }) + + it('leaves certificate and lock extensions unlisted', () => { + for (const extension of ['pem', 'crt', 'key', 'cer', 'lock']) { + expect(languageForPath(`secret.${extension}`), extension).toBeUndefined() + } + }) +}) + +describe('CODE_HIGHLIGHT_EXTENSIONS', () => { + it('lists every recognized suffix exactly once', () => { + expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) + expect(CODE_HIGHLIGHT_EXTENSIONS.length).toBeGreaterThan(100) + }) + + it('resolves each listed suffix back to a language', () => { + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + expect(languageForPath(`file.${extension}`), extension).toBeDefined() + } + }) +}) diff --git a/packages/util/code-language/tsconfig.json b/packages/util/code-language/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/code-language/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ffe4df02d..a30dfcb441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4053,6 +4053,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../store + '@deepseek-ai/dsh-util-code-language': + specifier: workspace:^ + version: link:../../util/code-language '@shikijs/langs': specifier: ^4.3.1 version: 4.3.1 @@ -7806,6 +7809,9 @@ importers: packages/fs/tool-fs: dependencies: + '@deepseek-ai/dsh-util-code-language': + specifier: workspace:^ + version: link:../../util/code-language '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -12519,6 +12525,12 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis + packages/util/code-language: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + packages/util/crypto: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 6c6663f286..f22f05910b 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -91,6 +91,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/util/crypto': 'Zero-dependency identifier minting utility.', 'packages/util/deque': 'Zero-dependency circular deque utility.', 'packages/util/chunked-list': 'Persistent collection operations and checkpoint validation without a plugin surface.', + 'packages/util/code-language': 'Zero-dependency file-extension to syntax-highlighting language table.', 'packages/util/home-paths': 'Zero-dependency harness-home path resolver.', 'packages/util/launch-environment': 'Zero-dependency environment resolver.', 'packages/util/lazy-require': 'Caller-relative CommonJS-compatible dependency loader.', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 57903b0e03..06d86db2ff 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -34,6 +34,7 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ '@deepseek-ai/dsh-brand', '@deepseek-ai/dsh-lazy-require', '@deepseek-ai/dsh-typert-protocol', + '@deepseek-ai/dsh-util-code-language', '@deepseek-ai/dsh-util-crypto', '@deepseek-ai/dsh-util-values', ] diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 2c4c147dd7..ced2c141a3 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -206,6 +206,7 @@ describe('package dependency scope', () => { '@deepseek-ai/dsh-brand', '@deepseek-ai/dsh-lazy-require', '@deepseek-ai/dsh-typert-protocol', + '@deepseek-ai/dsh-util-code-language', '@deepseek-ai/dsh-util-crypto', '@deepseek-ai/dsh-util-values', ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6a4ee18a25..4864264e68 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -36,6 +36,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { 'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.', 'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.', 'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.', + 'packages/util/code-language': 'The package only maps a filename suffix to a syntax-highlighting language id; the read consumer owns the persisted hint.', } /** diff --git a/tsconfig.base.json b/tsconfig.base.json index f386f12aba..fbf34b6997 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -305,6 +305,7 @@ "@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], "@deepseek-ai/dsh-util-values": ["./packages/util/values/src"], + "@deepseek-ai/dsh-util-code-language": ["./packages/util/code-language/src"], // util/ folders are role-named without the util- prefix their npm names carry, // so these aliases stay hand-written like dsh-util-crypto above. "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 50c576de65..b6ff4da739 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -218,6 +218,7 @@ { "path": "./packages/util/chunked-list" }, { "path": "./packages/util/values" }, { "path": "./packages/util/workspace-path" }, + { "path": "./packages/util/code-language" }, { "path": "./packages/util/output-retention" }, { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, From a964bd34b5ff9296f0c1c57f1b363bffa740b753 Mon Sep 17 00:00:00 2001 From: gengruilin Date: Mon, 21 Sep 2026 18:14:56 +0800 Subject: [PATCH 011/282] =?UTF-8?q?fix(tools):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E5=AE=B9=E5=99=A8=E6=A0=A1=E9=AA=8C=E5=B9=B6?= =?UTF-8?q?=E5=BD=92=E4=BD=8D=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/tools/README.i18n.yaml | 4 +-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/json-schema.ts | 6 ++-- packages/core/tools/tests/json-schema.spec.ts | 28 ++++++++++++++++- .../cordis-host-runner/README.i18n.yaml | 4 +-- .../extensions/cordis-host-runner/README.md | 2 ++ .../cordis-host-runner/README.zh.md | 2 ++ .../cordis-host-runner/src/guard.ts | 6 ++-- .../cordis-host-runner/tests/sandbox.spec.ts | 30 +++++++++++++++++++ .../ptc-runtime-node/src/json-wire.ts | 2 +- .../values}/tests/json.spec.ts | 0 .../workflow/workflow-ptc/src/guest-source.ts | 2 +- 13 files changed, 75 insertions(+), 15 deletions(-) rename packages/{core/session => util/values}/tests/json.spec.ts (100%) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 81cbe5aaa8..a03bc96d40 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: b5400d6b8743d904f5ff07a5fef50e041633ffbb -README.zh.md: 1bfd05db5c333215544b515a52364edbd6c126d8 +README.md: cbf4a72769b6702f56f1066718ec22de5fde6dcc +README.zh.md: 4c0600ada835cf0410b2a3c4a3913f610f758128 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index b5400d6b87..cbf4a72769 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -57,7 +57,7 @@ ctx.tools.register(defineTool({ })) ``` -The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; `InferValue` preserves exact types through 16 container levels before widening to `JsonValue`. A raw JSON Schema (`JsonSchemaNode`) is the wire-level counterpart shared with subagents, workflows, and MCP. +The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; `InferValue` preserves exact types through 16 container levels before widening to `JsonValue`. A raw JSON Schema (`JsonSchemaNode`) is the wire-level counterpart shared with subagents, workflows, and MCP. Schema containers accept intrinsic objects and dense arrays across realms using the current engine’s native constructor representation; class instances and forged prototypes are rejected. ### Configure the presentation mode diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 1bfd05db5c..4c0600ada8 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -57,7 +57,7 @@ ctx.tools.register(defineTool({ })) ``` -统一 schema DSL 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json` 与恰好匹配一个分支的 `oneOf`;`InferValue` 在 16 层容器内保留精确类型,之后加宽为 `JsonValue`。原始 JSON Schema(`JsonSchemaNode`)是与 subagent、工作流和 MCP 共享的协议级对应类型。 +统一 schema DSL 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json` 与恰好匹配一个分支的 `oneOf`;`InferValue` 在 16 层容器内保留精确类型,之后加宽为 `JsonValue`。原始 JSON Schema(`JsonSchemaNode`)是与 subagent、工作流和 MCP 共享的协议级对应类型。schema 容器通过当前引擎的原生构造器表示识别跨 realm 的内建对象与稠密数组;类实例与伪造原型会被拒绝。 ### 配置呈现模式 diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index c064fb294c..6d5103fc27 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -86,8 +86,8 @@ const CONSTRAINT_KEYWORDS = new Set([ const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples']) const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'] -/* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */ -/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +/* jscpd:ignore-start -- schema containers share util-values' intrinsic-prototype checks */ +/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') const constructor: unknown = descriptor?.value @@ -95,7 +95,7 @@ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): b try { return constructor.name === name && constructor.prototype === prototype - && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === 'Array' ? Array : Object) } catch { return false } diff --git a/packages/core/tools/tests/json-schema.spec.ts b/packages/core/tools/tests/json-schema.spec.ts index fc114daa93..ae623954f5 100644 --- a/packages/core/tools/tests/json-schema.spec.ts +++ b/packages/core/tools/tests/json-schema.spec.ts @@ -1,5 +1,5 @@ import { runInNewContext } from 'node:vm' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { assertObjectJsonSchema, assertSupportedJsonSchema, @@ -242,6 +242,32 @@ describe('the enforced raw JSON Schema subset', () => { expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow() }) + it('accepts schema records and lists with WebKit native constructor formatting', () => { + const schema = { type: 'object', properties: { value: { type: 'string', enum: ['x'] } }, required: ['value'] } + const originalDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, 'toString')! + const originalToString = originalDescriptor.value as (this: unknown) => string + const foreign = runInNewContext(`({ Object, Array, schema: ${JSON.stringify(schema)} })`) as { + Object: ObjectConstructor + Array: ArrayConstructor + schema: unknown + } + const toString = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) { + if (this === Object || this === foreign.Object) return 'function Object() {\n [native code]\n}' + if (this === Array || this === foreign.Array) return 'function Array() {\n [native code]\n}' + return originalToString.call(this) + }) + try { + for (const candidate of [schema, foreign.schema]) { + expect(() => { assertObjectJsonSchema(candidate) }).not.toThrow() + } + expect(violationsOf(recordWithForgedIntrinsicPrototype({ type: 'object' }))) + .toEqual(['schema must be a schema object']) + } finally { + toString.mockRestore() + } + expect(Object.getOwnPropertyDescriptor(Function.prototype, 'toString')).toEqual(originalDescriptor) + }) + it('rejects cyclic/exotic schema structure but permits sibling reuse', () => { const cyclic: Record = { type: 'object' } cyclic.properties = { self: cyclic } diff --git a/packages/extensions/cordis-host-runner/README.i18n.yaml b/packages/extensions/cordis-host-runner/README.i18n.yaml index cd4b42075c..86bb9bc443 100644 --- a/packages/extensions/cordis-host-runner/README.i18n.yaml +++ b/packages/extensions/cordis-host-runner/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/extensions/cordis-host-runner/README.md -README.md: 67df264fe2b4a76df6587a726e72e6300c507912 -README.zh.md: 0c8659078e3a78d3ce7284ac4d2e988902f89143 +README.md: 3ed852c109f8c6480d598baf2363a5f25bc92327 +README.zh.md: 37e9acedbb32d94d5f2b8b95726512c24e2771e2 diff --git a/packages/extensions/cordis-host-runner/README.md b/packages/extensions/cordis-host-runner/README.md index 67df264fe2..3ed852c109 100644 --- a/packages/extensions/cordis-host-runner/README.md +++ b/packages/extensions/cordis-host-runner/README.md @@ -45,6 +45,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Programmatic callers use `define`, `run`, `stop`, and `undefine`; the browser panel operates existing definitions. Host-only packages activate in this process. A package with a browser half waits for approval or cancellation, then loads Host before Client. `mode: "run"` starts the current version; `mode: "update"` replaces it. Stop disposes the live effects and retains the definition; undefine also forgets it. +Dynamic tool schemas and JSON return values recognize intrinsic containers across realms using the current engine’s native constructor representation; return values are copied into the Host realm, and class instances and forged prototypes are rejected. + ### What happens to definitions Definitions are session-scoped and process-local: other sessions read them as absent, and restart clears them. Historical logs retain tool arguments and receipts but do not restore the registry. Reloading a browser page requires another explicit run to load its Client half. diff --git a/packages/extensions/cordis-host-runner/README.zh.md b/packages/extensions/cordis-host-runner/README.zh.md index 0c8659078e..37e9acedbb 100644 --- a/packages/extensions/cordis-host-runner/README.zh.md +++ b/packages/extensions/cordis-host-runner/README.zh.md @@ -45,6 +45,8 @@ kind: "package-reference" 程序调用方使用 `define`、`run`、`stop` 和 `undefine`;浏览器面板操作已有定义。仅含 Host 的包在本进程激活。带浏览器部分的包等待审批或取消,批准后先加载 Host 再加载 Client。`mode: "run"` 启动当前版本,`mode: "update"` 替换版本。Stop 释放运行中的 effect 并保留定义;undefine 还会移除定义。 +动态工具的 schema 与 JSON 返回值使用当前引擎的原生构造器表示识别跨 realm 的内建容器;返回值会复制到 Host realm,类实例与伪造原型会被拒绝。 + ### 定义的去向 定义按会话隔离且仅在进程内存在:其他会话无法读取,重启会清空。历史日志保留工具参数和回执,但不会恢复注册表。浏览器页面重载后,需要再次显式运行才能加载 Client 部分。 diff --git a/packages/extensions/cordis-host-runner/src/guard.ts b/packages/extensions/cordis-host-runner/src/guard.ts index fb882c6770..d7cb4e8afc 100644 --- a/packages/extensions/cordis-host-runner/src/guard.ts +++ b/packages/extensions/cordis-host-runner/src/guard.ts @@ -38,8 +38,8 @@ function isPlainRecord(value: unknown): value is Record { && hasIntrinsicConstructor(prototype, 'Object') } -/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */ -/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +/* jscpd:ignore-start -- sandbox containers share util-values' intrinsic-prototype checks */ +/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor') const constructor: unknown = descriptor?.value @@ -47,7 +47,7 @@ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): b try { return constructor.name === name && constructor.prototype === prototype - && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }` + && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === 'Array' ? Array : Object) } catch { return false } diff --git a/packages/extensions/cordis-host-runner/tests/sandbox.spec.ts b/packages/extensions/cordis-host-runner/tests/sandbox.spec.ts index 89869a4393..184e70cd9d 100644 --- a/packages/extensions/cordis-host-runner/tests/sandbox.spec.ts +++ b/packages/extensions/cordis-host-runner/tests/sandbox.spec.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it, vi } from 'vitest' import { sandboxDefineTool } from '../src/guard.ts' import { syntaxErrorContext } from '../src/sandbox.ts' @@ -12,6 +13,35 @@ import { AGENT_A, call, CONTENT_OUTPUT_CODE, mount, setup, text, running } from */ describe('dynamic tool declaration boundary', () => { + it('accepts dynamic schemas and renderer values with WebKit native constructor formatting', () => { + const foreign = runInNewContext(`({ Object, Array, options: { + name: 'sample', description: 'sample', + parameters: { value: { type: 'string', enum: ['x'] } }, + output: { schema: { type: 'string' }, render: () => [{ type: 'text', text: 'ok' }] }, + execute: async () => 'ok', + } })`) as { Object: ObjectConstructor; Array: ArrayConstructor; options: Record } + const originalDescriptor = Object.getOwnPropertyDescriptor(Function.prototype, 'toString')! + const originalToString = originalDescriptor.value as (this: unknown) => string + const toString = vi.spyOn(Function.prototype, 'toString').mockImplementation(function (this: unknown) { + if (this === Object || this === foreign.Object) return 'function Object() {\n [native code]\n}' + if (this === Array || this === foreign.Array) return 'function Array() {\n [native code]\n}' + return originalToString.call(this) + }) + try { + for (const options of [foreign.options, { ...foreign.options }]) { + const definition = sandboxDefineTool(options) + expect(definition.parameters.properties).toEqual({ value: { type: 'string', enum: ['x'] } }) + const rendered = definition.output.render({ value: 'x' }, 'ok') + expect(rendered).toEqual([{ type: 'text', text: 'ok' }]) + expect(Object.getPrototypeOf(rendered)).toBe(Array.prototype) + } + expect(() => sandboxDefineTool(new Date(0))).toThrow('options must be an object') + } finally { + toString.mockRestore() + } + expect(Object.getOwnPropertyDescriptor(Function.prototype, 'toString')).toEqual(originalDescriptor) + }) + it.each([ [42, 'options must be an object'], [{ parameters: {} }, 'output must declare { schema, render, presentationMeta? }'], diff --git a/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts b/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts index 2a15d68383..73296d603f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts @@ -79,7 +79,7 @@ function setDelete(target: Set, value: T): void { intrinsicReflectApply(intrinsicSetDelete, target, [value]) } -/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */ +/** Check Node/V8's native constructor text through intrinsics captured before model code runs. */ function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean { const descriptor = intrinsicObjectGetOwnPropertyDescriptor(prototype, 'constructor') const constructor: unknown = descriptor?.value diff --git a/packages/core/session/tests/json.spec.ts b/packages/util/values/tests/json.spec.ts similarity index 100% rename from packages/core/session/tests/json.spec.ts rename to packages/util/values/tests/json.spec.ts diff --git a/packages/workflow/workflow-ptc/src/guest-source.ts b/packages/workflow/workflow-ptc/src/guest-source.ts index ed380d0559..22258a749c 100644 --- a/packages/workflow/workflow-ptc/src/guest-source.ts +++ b/packages/workflow/workflow-ptc/src/guest-source.ts @@ -1,4 +1,4 @@ /** Generated by scripts/gen-workflow-guest.ts. Do not edit directly. */ /** Self-contained ESM guest transported into the mounted PTC runtime. */ -export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tconst assign = (destination, item) => {\n\t\tif (destination === void 0) return;\n\t\tif (destination.kind === \"root\") root = item;\n\t\telse if (destination.kind === \"array\") destination.target[destination.index] = item;\n\t\telse Object.defineProperty(destination.target, destination.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst tasks = [{\n\t\tkind: \"visit\",\n\t\tvalue,\n\t\t...detach ? { destination: { kind: \"root\" } } : {}\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tancestors.delete(task.source);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"array-item\") {\n\t\t\tif (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.index],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tindex: task.index\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-property\") {\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.key],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tkey: task.key\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst current = task.value;\n\t\tif (current === null) {\n\t\t\tassign(task.destination, null);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"boolean\" || typeof current === \"string\") {\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current !== \"object\") return void 0;\n\t\tif (ancestors.has(current)) return void 0;\n\t\tif (Array.isArray(current)) {\n\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\tconst length = current.length;\n\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\tconst target = detach ? [] : void 0;\n\t\t\tif (target !== void 0) assign(task.destination, target);\n\t\t\tancestors.add(current);\n\t\t\ttasks.push({\n\t\t\t\tkind: \"leave\",\n\t\t\t\tsource: current\n\t\t\t});\n\t\t\tfor (let index = length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"array-item\",\n\t\t\t\tsource: current,\n\t\t\t\tindex,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\tconst keys = enumerableStringKeys(current);\n\t\tif (keys === void 0) return void 0;\n\t\tconst target = detach ? {} : void 0;\n\t\tif (target !== void 0) assign(task.destination, target);\n\t\tancestors.add(current);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tsource: current\n\t\t});\n\t\tfor (let index = keys.length - 1; index >= 0; index--) {\n\t\t\tconst key = keys[index];\n\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\tif (key === void 0) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"object-property\",\n\t\t\t\tsource: current,\n\t\t\t\tkey,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t}\n\t}\n\treturn detach ? root : true;\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" +export const WORKFLOW_GUEST_SOURCE = "import * as vm from \"node:vm\";\n//#region packages/workflow/workflow-ptc/src/realm.ts\n/**\n* Materializes script VM values as plain JSON and renders thrown values.\n* Getters and proxy traps may execute inside the confined Node process;\n* process isolation and cancellation belong to PTC, not the VM.\n* @module @deepseek-ai/dsh-workflow-ptc/realm\n*/\n/** Thrown by {@link materializeFromRealm}; the caller wraps it into the right `WorkflowError` code. */\nvar MaterializeError = class extends Error {\n\tpath;\n\treason;\n\tconstructor(path, reason) {\n\t\tsuper(`${path}: ${reason}`);\n\t\tthis.path = path;\n\t\tthis.reason = reason;\n\t\tthis.name = \"MaterializeError\";\n\t}\n};\n/**\n* Render a thrown value to failure text without ever throwing: prefer the\n* `stack` (host or realm — a realm error's `stack` is a plain string read),\n* fall back to `message`, then `String()`. Reading those properties MAY run\n* script code; if that code itself throws, a fixed label is returned instead.\n* @param error - any value thrown in the host or guest realm.\n* @returns human-readable text for the failure report; prefers the stack.\n*/\nfunction renderThrown(error) {\n\ttry {\n\t\tconst stack = error?.stack;\n\t\tif (typeof stack === \"string\" && stack.length > 0) return stack;\n\t\tconst message = error?.message;\n\t\tif (typeof message === \"string\" && message.length > 0) return message;\n\t\treturn String(error);\n\t} catch {\n\t\treturn \"[unrenderable thrown value]\";\n\t}\n}\n/**\n* Whether an object's prototype chain represents a plain data object: `null`, or a prototype\n* whose own prototype is `null` (the realm's `Object.prototype` — which we\n* cannot compare by identity across realms). A `Date`/`Map`/class instance\n* has a longer chain and is rejected.\n*/\nfunction hasPlainPrototype(value) {\n\tconst proto = Object.getPrototypeOf(value);\n\tif (proto === null) return true;\n\treturn Object.getPrototypeOf(proto) === null;\n}\n/**\n* Copy `value` (typically from the vm realm) into plain host JSON data. Root `undefined` is\n* returned unchanged; nested `undefined` and values JSON cannot represent losslessly fail\n* with the offending path. Property accessors run normally, and a throwing read is wrapped\n* with its rendered failure.\n*\n* @param value - the realm value to materialize.\n* @param root - the path label for the root value (error messages).\n* @returns the host-realm copy (plain objects/arrays/scalars only).\n* @throws {@link MaterializeError} for unsupported values, cycles, sparse arrays, exotic\n* prototypes, or property reads that throw.\n*/\nfunction materializeFromRealm(value, root = \"value\") {\n\tif (value === void 0) return void 0;\n\ttry {\n\t\treturn materialize(value, root, /* @__PURE__ */ new Set());\n\t} catch (error) {\n\t\tif (error instanceof MaterializeError) throw error;\n\t\tthrow new MaterializeError(root, `reading the value threw: ${renderThrown(error)}`);\n\t}\n}\nfunction materialize(value, path, seen) {\n\tswitch (typeof value) {\n\t\tcase \"boolean\":\n\t\tcase \"string\": return value;\n\t\tcase \"number\":\n\t\t\tif (!Number.isFinite(value)) throw new MaterializeError(path, \"non-finite numbers are not JSON data\");\n\t\t\treturn value;\n\t\tcase \"bigint\": throw new MaterializeError(path, \"bigints are not JSON data\");\n\t\tcase \"function\": throw new MaterializeError(path, \"functions are not plain JSON data\");\n\t\tcase \"symbol\": throw new MaterializeError(path, \"symbols are not plain JSON data\");\n\t\tcase \"undefined\": throw new MaterializeError(path, \"undefined is not JSON data\");\n\t\tcase \"object\": break;\n\t}\n\tif (value === null) return null;\n\tconst objectValue = value;\n\tif (seen.has(objectValue)) throw new MaterializeError(path, \"circular references are not JSON data\");\n\tseen.add(objectValue);\n\ttry {\n\t\tif (Array.isArray(objectValue)) return materializeArray(objectValue, path, seen);\n\t\treturn materializeObject(objectValue, path, seen);\n\t} finally {\n\t\tseen.delete(objectValue);\n\t}\n}\nfunction materializeArray(value, path, seen) {\n\tconst out = [];\n\tfor (let index = 0; index < value.length; index++) {\n\t\tif (!(index in value)) throw new MaterializeError(`${path}[${index}]`, \"sparse arrays are not JSON data\");\n\t\tout.push(materialize(value[index], `${path}[${index}]`, seen));\n\t}\n\tfor (const key of Object.keys(value)) {\n\t\tconst index = Number(key);\n\t\tif (!Number.isInteger(index) || index < 0 || index >= value.length) throw new MaterializeError(`${path}.${key}`, \"arrays with non-index properties are not JSON data\");\n\t}\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\treturn out;\n}\nfunction materializeObject(value, path, seen) {\n\tif (!hasPlainPrototype(value)) throw new MaterializeError(path, \"only plain objects and arrays are JSON data (exotic prototype)\");\n\tif (Object.getOwnPropertySymbols(value).length > 0) throw new MaterializeError(path, \"symbol-keyed properties are not plain JSON data\");\n\tconst out = {};\n\tfor (const key of Object.keys(value)) Object.defineProperty(out, key, {\n\t\tvalue: materialize(value[key], `${path}.${key}`, seen),\n\t\tenumerable: true,\n\t\twritable: true,\n\t\tconfigurable: true\n\t});\n\treturn out;\n}\n//#endregion\n//#region packages/util/brand/src/index.ts\n/**\n* Apply a compile-time string brand without changing the value.\n* @param value - string admitted by the domain that owns the target brand.\n* @returns the same string with the requested compile-time brand.\n*/\nfunction brandString(value) {\n\treturn value;\n}\n//#endregion\n//#region packages/llm/llm/src/error.ts\n/**\n* Harness error base with a stable machine-routable code and chained cause.\n* Package errors extend it so tool results and replay can retain failure class.\n* @module @deepseek-ai/dsh-llm/error\n*/\n/**\n* Base class for all harness errors. Carries a `code` (stable, programmatic —\n* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the\n* human-readable `message`, and supports `cause` chaining via the standard\n* `ErrorOptions`. `name` defaults to the subclass constructor name.\n*/\nvar HarnessError = class extends Error {\n\t/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */\n\tcode;\n\tconstructor(message, code, options) {\n\t\tsuper(message, options);\n\t\tthis.code = code;\n\t\tthis.name = new.target.name;\n\t}\n};\nnew RegExp(String.raw`(?:^|[^a-z0-9])context[\\s_-](?:length|window)[\\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\\s_-]exceeded)(?:$|[^a-z0-9])`, \"i\");\nnew RegExp(String.raw`\\b(?:request|prompt|input|messages?)\\s+(?:is\\s+|are\\s+)?` + String.raw`too\\s+(?:large|long)\\s+for\\s+(?:(?:this|the)\\s+)?` + String.raw`(?:model(?:'s)?\\s+)?context(?:\\s+window)?\\b`, \"i\");\nnew RegExp(String.raw`\\b(?:input|prompt|request|messages?)\\b.{0,40}` + String.raw`\\b(?:exceed(?:s|ed)?|overflows?|is\\s+larger\\s+than)\\b.{0,40}` + String.raw`\\b(?:the\\s+)?(?:model(?:'s)?\\s+)?context(?:\\s+(?:length|window))?\\b`, \"i\");\n//#endregion\n//#region packages/util/values/src/index.ts\n/**\n* Mark an unreachable closed-union branch.\n* @param value - impossible value; an unhandled typed variant fails at the call site.\n* @param context - optional switch-site label included in the failure message.\n* @returns never; a runtime value that escaped its type always throws.\n*/\nfunction assertNever(value, context) {\n\tconst rendered = JSON.stringify(value) ?? String(value);\n\tthrow new Error(`unreachable variant${context ? ` in ${context}` : \"\"}: ${rendered}`);\n}\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor$1(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype$1(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor$1(value, \"Object\");\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */\nfunction hasPlainArrayPrototype$1(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor$1(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype$1(objectPrototype);\n}\n/** Whether an object is a plain or null-prototype record from any JavaScript realm. */\nfunction hasPlainObjectPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype$1(prototype);\n}\n/** Return every JSON-visible object key, or reject own data JSON would discard. */\nfunction enumerableStringKeys(value) {\n\tconst keys = Reflect.ownKeys(value);\n\tif (keys.some((key) => typeof key !== \"string\" || !Object.prototype.propertyIsEnumerable.call(value, key))) return void 0;\n\treturn keys;\n}\n/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */\nfunction walkJsonValue(value, detach) {\n\tconst ancestors = /* @__PURE__ */ new Set();\n\tlet root;\n\tconst assign = (destination, item) => {\n\t\tif (destination === void 0) return;\n\t\tif (destination.kind === \"root\") root = item;\n\t\telse if (destination.kind === \"array\") destination.target[destination.index] = item;\n\t\telse Object.defineProperty(destination.target, destination.key, {\n\t\t\tvalue: item,\n\t\t\tenumerable: true,\n\t\t\tconfigurable: true,\n\t\t\twritable: true\n\t\t});\n\t};\n\tconst tasks = [{\n\t\tkind: \"visit\",\n\t\tvalue,\n\t\t...detach ? { destination: { kind: \"root\" } } : {}\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tancestors.delete(task.source);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"array-item\") {\n\t\t\tif (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.index],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"array\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tindex: task.index\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-property\") {\n\t\t\ttasks.push({\n\t\t\t\tkind: \"visit\",\n\t\t\t\tvalue: task.source[task.key],\n\t\t\t\t...task.target === void 0 ? {} : { destination: {\n\t\t\t\t\tkind: \"object\",\n\t\t\t\t\ttarget: task.target,\n\t\t\t\t\tkey: task.key\n\t\t\t\t} }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst current = task.value;\n\t\tif (current === null) {\n\t\t\tassign(task.destination, null);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"boolean\" || typeof current === \"string\") {\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current === \"number\") {\n\t\t\tif (!Number.isFinite(current) || Object.is(current, -0)) return void 0;\n\t\t\tassign(task.destination, current);\n\t\t\tcontinue;\n\t\t}\n\t\tif (typeof current !== \"object\") return void 0;\n\t\tif (ancestors.has(current)) return void 0;\n\t\tif (Array.isArray(current)) {\n\t\t\tif (!hasPlainArrayPrototype$1(current)) return void 0;\n\t\t\tconst length = current.length;\n\t\t\tif (Reflect.ownKeys(current).length !== length + 1) return void 0;\n\t\t\tconst target = detach ? [] : void 0;\n\t\t\tif (target !== void 0) assign(task.destination, target);\n\t\t\tancestors.add(current);\n\t\t\ttasks.push({\n\t\t\t\tkind: \"leave\",\n\t\t\t\tsource: current\n\t\t\t});\n\t\t\tfor (let index = length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"array-item\",\n\t\t\t\tsource: current,\n\t\t\t\tindex,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasPlainObjectPrototype(current)) return void 0;\n\t\tconst keys = enumerableStringKeys(current);\n\t\tif (keys === void 0) return void 0;\n\t\tconst target = detach ? {} : void 0;\n\t\tif (target !== void 0) assign(task.destination, target);\n\t\tancestors.add(current);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tsource: current\n\t\t});\n\t\tfor (let index = keys.length - 1; index >= 0; index--) {\n\t\t\tconst key = keys[index];\n\t\t\t/* v8 ignore next -- the loop is bounded by the captured key count. */\n\t\t\tif (key === void 0) return void 0;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"object-property\",\n\t\t\t\tsource: current,\n\t\t\t\tkey,\n\t\t\t\t...target === void 0 ? {} : { target }\n\t\t\t});\n\t\t}\n\t}\n\treturn detach ? root : true;\n}\n/**\n* Test the same lossless JSON rules as {@link snapshotJsonValue} without detaching the value.\n* @param value - candidate value to test.\n* @returns whether the value survives a JSON round trip without loss.\n*/\nfunction isJsonValue(value) {\n\treturn walkJsonValue(value, false) === true;\n}\n//#endregion\n//#region packages/core/tools/src/json-schema.ts\n/**\n* Enforced JSON Schema subset shared by tool outputs, generated PTC mode\n* types, subagents, and workflows. The subset accepts any JSON root, an\n* annotation-only schema for unconstrained JSON, one scalar `type`, object\n* `properties`/`required`/boolean `additionalProperties`, array `items`,\n* type-correct scalar `enum`/`const`, and exact-one `oneOf`.\n*\n* Unsupported or misplaced keywords reject rather than being accepted without\n* enforcement. Consumers that require an object root apply\n* {@link assertObjectJsonSchema} before accepting input.\n* @module dsh-tools/json-schema\n*/\n/**\n* Thrown when a raw schema falls outside the enforced subset. `violations`\n* lists every offending path instead of stopping at the first author error.\n*/\nvar JsonSchemaError = class extends HarnessError {\n\t/** Individual schema violations in walk order. */\n\tviolations;\n\tconstructor(violations) {\n\t\tsuper(`unsupported JSON schema: ${violations.join(\"; \")}`, \"UNSUPPORTED_SCHEMA\");\n\t\tthis.name = \"JsonSchemaError\";\n\t\tthis.violations = violations;\n\t}\n};\nconst CONSTRAINT_KEYWORDS = new Set([\n\t\"type\",\n\t\"oneOf\",\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n]);\nconst ANNOTATION_KEYWORDS = new Set([\n\t\"description\",\n\t\"title\",\n\t\"default\",\n\t\"examples\"\n]);\nconst SCHEMA_TYPES = [\n\t\"object\",\n\t\"array\",\n\t\"string\",\n\t\"number\",\n\t\"integer\",\n\t\"boolean\",\n\t\"null\"\n];\n/** Whether a realm-owned intrinsic prototype has a native constructor matching this engine's representation. */\nfunction hasIntrinsicConstructor(prototype, name) {\n\tconst constructor = Object.getOwnPropertyDescriptor(prototype, \"constructor\")?.value;\n\tif (typeof constructor !== \"function\") return false;\n\ttry {\n\t\treturn constructor.name === name && constructor.prototype === prototype && Function.prototype.toString.call(constructor) === Function.prototype.toString.call(name === \"Array\" ? Array : Object);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether a candidate is one realm's intrinsic `Object.prototype`. */\nfunction isIntrinsicObjectPrototype(value) {\n\treturn Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, \"Object\");\n}\n/**\n* Test for a realm-agnostic plain JSON record without accepting arrays or\n* exotic objects.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the value has a plain-object prototype chain.\n*/\nfunction isPlainJsonRecord(value) {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n\ttry {\n\t\tconst prototype = Object.getPrototypeOf(value);\n\t\treturn prototype === null || typeof prototype === \"object\" && isIntrinsicObjectPrototype(prototype);\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Whether an array uses one realm's intrinsic `Array.prototype`. */\nfunction hasPlainArrayPrototype(value) {\n\tconst prototype = Object.getPrototypeOf(value);\n\tif (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, \"Array\")) return false;\n\tconst objectPrototype = Object.getPrototypeOf(prototype);\n\treturn typeof objectPrototype === \"object\" && objectPrototype !== null && isIntrinsicObjectPrototype(objectPrototype);\n}\n/** Return whether a record contains only own enumerable string keys. */\nfunction hasOnlyEnumerableStringKeys(value) {\n\ttry {\n\t\treturn Reflect.ownKeys(value).every((key) => typeof key === \"string\" && Object.prototype.propertyIsEnumerable.call(value, key));\n\t} catch {\n\t\treturn false;\n\t}\n}\n/**\n* Test for an ordinary schema record whose keys survive JSON projection.\n* @param value - candidate record from any JavaScript realm.\n* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.\n*/\nfunction isJsonSchemaRecord(value) {\n\treturn isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value);\n}\n/**\n* Test for a dense ordinary array with no JSON-invisible decorations.\n* @param value - candidate array from any JavaScript realm.\n* @returns Whether the array is intrinsic, dense, and undecorated.\n*/\nfunction isPlainJsonArray(value) {\n\tif (!Array.isArray(value)) return false;\n\ttry {\n\t\tif (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false;\n\t\tfor (let index = 0; index < value.length; index++) if (!Object.hasOwn(value, index)) return false;\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n/** Lossless finite JSON number, excluding negative zero. */\nfunction isJsonNumber(value) {\n\treturn typeof value === \"number\" && Number.isFinite(value) && !Object.is(value, -0);\n}\n/** Whether a scalar is valid for one declared schema type. */\nfunction scalarMatches(type, value) {\n\tswitch (type) {\n\t\tcase \"string\": return typeof value === \"string\";\n\t\tcase \"number\": return isJsonNumber(value);\n\t\tcase \"integer\": return isJsonNumber(value) && Number.isInteger(value);\n\t\tcase \"boolean\": return typeof value === \"boolean\";\n\t\tcase \"null\": return value === null;\n\t\t/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */\n\t\tdefault: return assertNever(type, \"JsonSchemaType\");\n\t}\n}\n/** Keywords that are invalid beside `oneOf`. */\nconst ONE_OF_SIBLING_KEYWORDS = [\n\t\"properties\",\n\t\"required\",\n\t\"additionalProperties\",\n\t\"items\",\n\t\"enum\",\n\t\"const\"\n];\n/** Validate object-only fields after its property schemas have been visited. */\nfunction checkObjectSchemaTail(node, path, properties, violations) {\n\tconst hasRequired = Object.hasOwn(node, \"required\");\n\tconst required = hasRequired ? node.required : void 0;\n\tif (hasRequired) if (!isPlainJsonArray(required) || required.some((entry) => typeof entry !== \"string\")) violations.push(`${path}.required must be an array of strings`);\n\telse {\n\t\tconst declared = isJsonSchemaRecord(properties) ? properties : {};\n\t\tfor (const key of required) if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names \"${key}\" which is not in properties`);\n\t}\n\tif (Object.hasOwn(node, \"additionalProperties\") && typeof node.additionalProperties !== \"boolean\") violations.push(`${path}.additionalProperties must be a boolean`);\n}\n/** Collect every violation for one raw schema tree without using the JavaScript call stack. */\nfunction checkSchemaNode(root, rootPath, violations, seen) {\n\tconst tasks = [{\n\t\tkind: \"enter\",\n\t\tnode: root,\n\t\tpath: rootPath\n\t}];\n\tfor (let task = tasks.pop(); task !== void 0; task = tasks.pop()) {\n\t\tif (task.kind === \"leave\") {\n\t\t\tseen.delete(task.node);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"one-of-tail\") {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (task.kind === \"object-tail\") {\n\t\t\tcheckObjectSchemaTail(task.node, task.path, task.properties, violations);\n\t\t\tcontinue;\n\t\t}\n\t\tconst { node, path } = task;\n\t\tif (!isJsonSchemaRecord(node)) {\n\t\t\tviolations.push(`${path} must be a schema object`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (seen.has(node)) {\n\t\t\tviolations.push(`${path} is circular`);\n\t\t\tcontinue;\n\t\t}\n\t\tseen.add(node);\n\t\ttasks.push({\n\t\t\tkind: \"leave\",\n\t\t\tnode\n\t\t});\n\t\tfor (const key of Object.keys(node)) {\n\t\t\tif (CONSTRAINT_KEYWORDS.has(key)) continue;\n\t\t\tif (ANNOTATION_KEYWORDS.has(key)) {\n\t\t\t\ttry {\n\t\t\t\t\tif (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t} catch {\n\t\t\t\t\tviolations.push(`${path}.${key} annotation must be lossless JSON data`);\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tviolations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`);\n\t\t}\n\t\tif (Object.hasOwn(node, \"description\") && typeof node.description !== \"string\") violations.push(`${path}.description must be a string`);\n\t\tif (Object.hasOwn(node, \"title\") && typeof node.title !== \"string\") violations.push(`${path}.title must be a string`);\n\t\tconst hasType = Object.hasOwn(node, \"type\");\n\t\tconst hasOneOf = Object.hasOwn(node, \"oneOf\");\n\t\tif (hasType && hasOneOf) {\n\t\t\tviolations.push(`${path} cannot declare both type and oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (!hasType && !hasOneOf) {\n\t\t\tfor (const key of ONE_OF_SIBLING_KEYWORDS) if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`);\n\t\t\tcontinue;\n\t\t}\n\t\tif (hasOneOf) {\n\t\t\tconst oneOf = node.oneOf;\n\t\t\ttasks.push({\n\t\t\t\tkind: \"one-of-tail\",\n\t\t\t\tnode,\n\t\t\t\tpath\n\t\t\t});\n\t\t\tif (!isPlainJsonArray(oneOf) || oneOf.length < 2) violations.push(`${path}.oneOf must be an array of at least two schemas`);\n\t\t\telse for (let index = oneOf.length - 1; index >= 0; index--) tasks.push({\n\t\t\t\tkind: \"enter\",\n\t\t\t\tnode: oneOf[index],\n\t\t\t\tpath: `${path}.oneOf[${index}]`\n\t\t\t});\n\t\t\tcontinue;\n\t\t}\n\t\tconst type = node.type;\n\t\tif (typeof type !== \"string\" || !SCHEMA_TYPES.includes(type)) {\n\t\t\tviolations.push(Array.isArray(type) ? `${path}.type must be a single type string (type arrays are not supported)` : `${path}.type must be one of ${SCHEMA_TYPES.join(\"/\")}`);\n\t\t\tcontinue;\n\t\t}\n\t\tconst schemaType = type;\n\t\tfor (const [key, types] of Object.entries({\n\t\t\tproperties: [\"object\"],\n\t\t\trequired: [\"object\"],\n\t\t\tadditionalProperties: [\"object\"],\n\t\t\titems: [\"array\"],\n\t\t\tenum: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t],\n\t\t\tconst: [\n\t\t\t\t\"string\",\n\t\t\t\t\"number\",\n\t\t\t\t\"integer\",\n\t\t\t\t\"boolean\",\n\t\t\t\t\"null\"\n\t\t\t]\n\t\t})) if (Object.hasOwn(node, key) && !types.includes(schemaType)) violations.push(`${path}.${key} is not supported on type \"${schemaType}\"`);\n\t\tswitch (schemaType) {\n\t\t\tcase \"object\": {\n\t\t\t\tconst properties = Object.hasOwn(node, \"properties\") ? node.properties : void 0;\n\t\t\t\ttasks.push({\n\t\t\t\t\tkind: \"object-tail\",\n\t\t\t\t\tnode,\n\t\t\t\t\tpath,\n\t\t\t\t\tproperties\n\t\t\t\t});\n\t\t\t\tif (Object.hasOwn(node, \"properties\")) if (!isJsonSchemaRecord(properties)) violations.push(`${path}.properties must be an object of schemas`);\n\t\t\t\telse {\n\t\t\t\t\tconst entries = Object.entries(properties);\n\t\t\t\t\tfor (let index = entries.length - 1; index >= 0; index--) {\n\t\t\t\t\t\tconst entry = entries[index];\n\t\t\t\t\t\t/* v8 ignore next -- the loop is bounded by the captured entry count. */\n\t\t\t\t\t\tif (entry === void 0) continue;\n\t\t\t\t\t\ttasks.push({\n\t\t\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\t\t\tnode: entry[1],\n\t\t\t\t\t\t\tpath: `${path}.properties.${entry[0]}`\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"array\":\n\t\t\t\tif (Object.hasOwn(node, \"items\")) tasks.push({\n\t\t\t\t\tkind: \"enter\",\n\t\t\t\t\tnode: node.items,\n\t\t\t\t\tpath: `${path}.items`\n\t\t\t\t});\n\t\t\t\tbreak;\n\t\t\tcase \"string\":\n\t\t\tcase \"number\":\n\t\t\tcase \"integer\":\n\t\t\tcase \"boolean\":\n\t\t\tcase \"null\": {\n\t\t\t\tconst hasEnum = Object.hasOwn(node, \"enum\");\n\t\t\t\tconst allowed = hasEnum ? node.enum : void 0;\n\t\t\t\tconst enumValid = isPlainJsonArray(allowed) && allowed.length > 0 && allowed.every((entry) => scalarMatches(schemaType, entry));\n\t\t\t\tif (hasEnum && !enumValid) violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`);\n\t\t\t\tconst hasConst = Object.hasOwn(node, \"const\");\n\t\t\t\tconst declaredConst = hasConst ? node.const : void 0;\n\t\t\t\tconst constValid = scalarMatches(schemaType, declaredConst);\n\t\t\t\tif (hasConst) {\n\t\t\t\t\tif (!constValid) violations.push(`${path}.const must be a ${schemaType} value`);\n\t\t\t\t\telse if (enumValid && !allowed.includes(declaredConst)) violations.push(`${path}.const must be one of ${path}.enum when both are declared`);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */\n\t\t\tdefault: assertNever(schemaType, \"JsonSchemaType\");\n\t\t}\n\t}\n}\n/**\n* Assert the enforced subset plus the object-root constraint retained by\n* subagent and workflow structured outputs.\n* @param schema - untrusted caller-supplied schema.\n* @returns Assertion that the schema belongs to the supported subset and has an object root.\n*/\nfunction assertObjectJsonSchema(schema) {\n\tconst violations = [];\n\tcheckSchemaNode(schema, \"schema\", violations, /* @__PURE__ */ new Set());\n\tif (violations.length === 0 && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, \"type\") || schema.type !== \"object\")) violations.push(\"schema.type must be \\\"object\\\" (structured output is object-rooted)\");\n\tif (violations.length > 0) throw new JsonSchemaError(violations);\n}\n//#endregion\n//#region packages/workflow/workflow/src/index.ts\n/**\n* Typed error for workflow-seam failures. Extends {@link HarnessError}, so the\n* `code` is machine-routable taxonomy. `fatal` drives the combinator\n* discipline: `parallel()`/`pipeline()` re-throw a fatal error (a typo'd\n* option or a tripped cap must kill the script loudly), and reserve the\n* per-item `null` for child-run failures and ordinary in-stage script errors.\n* Every {@link WorkflowErrorCode} is fatal; the flag exists so the\n* distinction is explicit at every catch site rather than implied.\n*/\nvar WorkflowError = class extends HarnessError {\n\t/** Whether combinators must propagate this error instead of nulling the item. */\n\tfatal;\n\tconstructor(message, code, options) {\n\t\tsuper(message, code, options);\n\t\tthis.name = \"WorkflowError\";\n\t\tthis.fatal = options?.fatal ?? true;\n\t}\n};\n/**\n* Whether combinators must re-throw `error` instead of mapping the item to `null`.\n* @param error - any thrown value; fatality is host `instanceof` (unforgeable from a script realm).\n* @returns true iff `error` is a {@link WorkflowError} whose `fatal` flag is set.\n*/\nfunction isFatalWorkflowError(error) {\n\treturn error instanceof WorkflowError && error.fatal;\n}\n//#endregion\n//#region packages/workflow/workflow-ptc/src/runtime.ts\n/**\n* Workflow VM hooks, child callbacks, ordinary concurrency limits and result serialization.\n* PTC owns process confinement and cancellation. Fatal hook and provider failures propagate\n* through combinators; ordinary child failures and stage errors become per-item nulls.\n* @module @deepseek-ai/dsh-workflow-ptc/runtime\n*/\n/** The `agent()` options the script may pass; everything else rejects loud. */\nconst SUPPORTED_AGENT_OPTIONS = new Set([\n\t\"label\",\n\t\"phase\",\n\t\"schema\",\n\t\"provider\",\n\t\"model\"\n]);\n/** Deferred Claude Code options we name explicitly in the rejection message. */\nconst DEFERRED_AGENT_OPTIONS = new Set([\n\t\"effort\",\n\t\"isolation\",\n\t\"agentType\"\n]);\n/** Flatten a child's final output blocks to text (the non-schema `agent()` result). */\nfunction outputText(blocks) {\n\treturn blocks.filter((block) => block.type === \"text\").map((block) => block.text).join(\"\");\n}\n/** A short display label derived from the prompt when the script passes none. */\nfunction defaultLabel(prompt) {\n\tconst newline = prompt.indexOf(\"\\n\");\n\tconst line = newline === -1 ? prompt : prompt.slice(0, newline);\n\treturn line.length <= 48 ? line : `${line.slice(0, 47)}…`;\n}\n/**\n* One script execution inside the confined Node process. The host owns\n* cancellation and cleanup of any dropped child work.\n*/\nvar WorkflowExecution = class {\n\tlimits;\n\tobserver;\n\tchildren;\n\t/** 1-based count of `agent()` calls started (the `agentsStarted` result field). */\n\tstarted = 0;\n\tactiveSlots = 0;\n\tslotWaiters = [];\n\tcurrentPhase;\n\tcontext;\n\tcompiled;\n\tconstructor(meta, body, args, limits, observer, children) {\n\t\tthis.limits = limits;\n\t\tthis.observer = observer;\n\t\tthis.children = children;\n\t\ttry {\n\t\t\tthis.compiled = new vm.Script(`(async () => {\\n${body}\\n})()`, {\n\t\t\t\tfilename: `workflow:${meta.name}`,\n\t\t\t\tlineOffset: -1\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tthrow new WorkflowError(`workflow script does not parse: ${String(error)}`, \"SCRIPT_PARSE\", { cause: error });\n\t\t}\n\t\tthis.context = vm.createContext({}, { name: `workflow:${meta.name}` });\n\t\tconst globals = {\n\t\t\tagent: (prompt, opts) => this.contain(this.agent(prompt, opts)),\n\t\t\tparallel: (thunks) => this.contain(this.parallel(thunks)),\n\t\t\tpipeline: (items, ...stages) => this.contain(this.pipeline(items, stages)),\n\t\t\tphase: (title) => {\n\t\t\t\tthis.phase(title);\n\t\t\t},\n\t\t\tlog: (message) => {\n\t\t\t\tthis.log(message);\n\t\t\t},\n\t\t\targs\n\t\t};\n\t\tfor (const [key, value] of Object.entries(globals)) this.context[key] = typeof value === \"function\" ? Object.freeze(value) : value;\n\t}\n\t/**\n\t* Run the script and materialize its JSON return value.\n\t* @returns A completed or error result; script failures never reject.\n\t*/\n\tasync drive() {\n\t\ttry {\n\t\t\tconst scriptPromise = this.compiled.runInContext(this.context, { timeout: this.limits.syncTimeoutMs });\n\t\t\tconst raw = await this.contain(Promise.resolve(scriptPromise));\n\t\t\treturn {\n\t\t\t\tvalue: raw === void 0 ? null : this.materializeResult(raw),\n\t\t\t\tstopReason: \"completed\",\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tvalue: null,\n\t\t\t\tstopReason: \"error\",\n\t\t\t\terror: renderThrown(error),\n\t\t\t\tagentsStarted: this.started\n\t\t\t};\n\t\t}\n\t}\n\t/**\n\t* Attach a no-op rejection consumer WITHOUT changing what the caller\n\t* receives: if the script drops the promise, a host rejection cannot become\n\t* an unhandled rejection that kills the process; if\n\t* the script does await it, it still observes the rejection.\n\t*/\n\tcontain(promise) {\n\t\tpromise.catch(() => {});\n\t\treturn promise;\n\t}\n\t/** Materialize the script's return value; violations become RESULT_UNSERIALIZABLE. */\n\tmaterializeResult(raw) {\n\t\ttry {\n\t\t\treturn materializeFromRealm(raw, \"workflow result\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`the workflow's return value is not plain JSON data — ${error.message}. Return only JSON-serializable objects/arrays/scalars.`, \"RESULT_UNSERIALIZABLE\", { cause: error });\n\t\t}\n\t}\n\t/** Acquire one concurrency slot in FIFO order. */\n\tacquireSlot() {\n\t\tif (this.activeSlots < this.limits.maxConcurrentAgents) {\n\t\t\tthis.activeSlots += 1;\n\t\t\treturn Promise.resolve();\n\t\t}\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.slotWaiters.push(() => {\n\t\t\t\tthis.activeSlots += 1;\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\treleaseSlot() {\n\t\tthis.activeSlots -= 1;\n\t\tconst next = this.slotWaiters.shift();\n\t\tif (next) next();\n\t}\n\t/** The `agent(prompt, opts)` hook. */\n\tasync agent(rawPrompt, rawOpts) {\n\t\tif (typeof rawPrompt !== \"string\" || rawPrompt.length === 0) throw new WorkflowError(\"agent() requires a non-empty prompt string\", \"INVALID_ARGUMENT\");\n\t\tconst opts = this.readAgentOptions(rawOpts);\n\t\tif (this.started >= this.limits.maxTotalAgents) throw new WorkflowError(`this run reached its total agent cap (${this.limits.maxTotalAgents}) — a runaway-loop backstop; raise the applicable maxTotalAgents limit if the scale is intentional`, \"AGENT_CAP\");\n\t\tthis.started += 1;\n\t\tconst seq = this.started;\n\t\tconst label = opts.label ?? defaultLabel(rawPrompt);\n\t\tconst phase = opts.phase ?? this.currentPhase;\n\t\tawait this.acquireSlot();\n\t\ttry {\n\t\t\tlet run;\n\t\t\ttry {\n\t\t\t\trun = await this.children.startAgent({\n\t\t\t\t\tprompt: rawPrompt,\n\t\t\t\t\t...opts.schema !== void 0 ? { schema: opts.schema } : {},\n\t\t\t\t\t...opts.provider !== void 0 ? { provider: opts.provider } : {},\n\t\t\t\t\t...opts.model !== void 0 ? { model: opts.model } : {}\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tthrow new WorkflowError(`agent() could not start a child: ${renderThrown(error)}`, \"AGENT_START\", { cause: error });\n\t\t\t}\n\t\t\tconst info = {\n\t\t\t\tseq,\n\t\t\t\tlabel,\n\t\t\t\t...phase !== void 0 ? { phase } : {},\n\t\t\t\tchildId: brandString(run.id)\n\t\t\t};\n\t\t\tthis.observer.agentStart(info);\n\t\t\ttry {\n\t\t\t\tlet result;\n\t\t\t\ttry {\n\t\t\t\t\tresult = await run.result;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t});\n\t\t\t\t\tthrow new WorkflowError(`child agent run failed: ${renderThrown(error)}`, \"AGENT_RESULT\", { cause: error });\n\t\t\t\t}\n\t\t\t\tif (result.stopReason === \"completed\") {\n\t\t\t\t\tif (opts.schema !== void 0) {\n\t\t\t\t\t\tif (result.structured === void 0) {\n\t\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t\t...info,\n\t\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t\t});\n\t\t\t\t\t\treturn result.structured;\n\t\t\t\t\t}\n\t\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t\t...info,\n\t\t\t\t\t\toutcome: \"completed\"\n\t\t\t\t\t});\n\t\t\t\t\treturn outputText(result.output);\n\t\t\t\t}\n\t\t\t\tthis.observer.agentEnd({\n\t\t\t\t\t...info,\n\t\t\t\t\toutcome: \"failed\"\n\t\t\t\t});\n\t\t\t\treturn null;\n\t\t\t} finally {\n\t\t\t\tawait run.dispose();\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.releaseSlot();\n\t\t}\n\t}\n\t/** Materialize + validate the `agent()` options bag from the realm. */\n\treadAgentOptions(rawOpts) {\n\t\tif (rawOpts === void 0) return {};\n\t\tlet opts;\n\t\ttry {\n\t\t\topts = materializeFromRealm(rawOpts, \"agent() options\");\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: materializeFromRealm only throws MaterializeError */\n\t\t\tif (!(error instanceof MaterializeError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() options must be plain JSON data — ${error.message}`, \"INVALID_ARGUMENT\", { cause: error });\n\t\t}\n\t\tif (typeof opts !== \"object\" || opts === null || Array.isArray(opts)) throw new WorkflowError(\"agent() options must be an object\", \"INVALID_ARGUMENT\");\n\t\tconst record = opts;\n\t\tfor (const key of Object.keys(record)) {\n\t\t\tif (SUPPORTED_AGENT_OPTIONS.has(key)) continue;\n\t\t\tif (DEFERRED_AGENT_OPTIONS.has(key)) throw new WorkflowError(`agent() option \"${key}\" is deferred and not supported by this engine (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t\tthrow new WorkflowError(`agent() option \"${key}\" is not recognized (supported: label, phase, schema, provider, model)`, \"UNSUPPORTED_OPTION\");\n\t\t}\n\t\tfor (const key of [\n\t\t\t\"label\",\n\t\t\t\"phase\",\n\t\t\t\"provider\",\n\t\t\t\"model\"\n\t\t]) if (record[key] !== void 0 && typeof record[key] !== \"string\") throw new WorkflowError(`agent() option \"${key}\" must be a string`, \"INVALID_ARGUMENT\");\n\t\tlet schema;\n\t\tif (record.schema !== void 0) try {\n\t\t\tassertObjectJsonSchema(record.schema);\n\t\t\tschema = record.schema;\n\t\t} catch (error) {\n\t\t\t/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */\n\t\t\tif (!(error instanceof JsonSchemaError)) throw error;\n\t\t\tthrow new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, \"UNSUPPORTED_SCHEMA\", { cause: error });\n\t\t}\n\t\treturn {\n\t\t\t...record.label !== void 0 ? { label: record.label } : {},\n\t\t\t...record.phase !== void 0 ? { phase: record.phase } : {},\n\t\t\t...record.provider !== void 0 ? { provider: record.provider } : {},\n\t\t\t...record.model !== void 0 ? { model: record.model } : {},\n\t\t\t...schema !== void 0 ? { schema } : {}\n\t\t};\n\t}\n\t/** The `parallel(thunks)` hook: each thunk caught → `null`; fatal errors propagate. */\n\tasync parallel(rawThunks) {\n\t\tif (!Array.isArray(rawThunks)) throw new WorkflowError(\"parallel() requires an array of zero-argument functions\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawThunks.length, \"parallel()\");\n\t\tconst thunks = rawThunks.map((thunk, index) => {\n\t\t\tif (typeof thunk !== \"function\") throw new WorkflowError(`parallel() item ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn thunk;\n\t\t});\n\t\treturn Promise.all(thunks.map(async (thunk) => {\n\t\t\ttry {\n\t\t\t\treturn await thunk();\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\t/** The `pipeline(items, ...stages)` hook: per-item stage chains, NO cross-stage barrier. */\n\tasync pipeline(rawItems, rawStages) {\n\t\tif (!Array.isArray(rawItems)) throw new WorkflowError(\"pipeline() requires an items array\", \"INVALID_ARGUMENT\");\n\t\tthis.assertItemCap(rawItems.length, \"pipeline()\");\n\t\tif (rawStages.length === 0) throw new WorkflowError(\"pipeline() requires at least one stage function\", \"INVALID_ARGUMENT\");\n\t\tconst stages = rawStages.map((stage, index) => {\n\t\t\tif (typeof stage !== \"function\") throw new WorkflowError(`pipeline() stage ${index} is not a function`, \"INVALID_ARGUMENT\");\n\t\t\treturn stage;\n\t\t});\n\t\treturn Promise.all(rawItems.map(async (item, index) => {\n\t\t\tlet value = item;\n\t\t\ttry {\n\t\t\t\tfor (const stage of stages) value = await stage(value, item, index);\n\t\t\t\treturn value;\n\t\t\t} catch (error) {\n\t\t\t\tif (isFatalWorkflowError(error)) throw error;\n\t\t\t\treturn null;\n\t\t\t}\n\t\t}));\n\t}\n\tassertItemCap(length, hook) {\n\t\tif (length > this.limits.maxItemsPerCall) throw new WorkflowError(`${hook} received ${length} items — over the per-call cap (${this.limits.maxItemsPerCall}); split the work or raise maxItemsPerCall in the engine config`, \"ITEM_CAP\");\n\t}\n\t/** The `phase(title)` hook: sets the current label for subsequent `agent()` calls and notifies observers. */\n\tphase(title) {\n\t\tif (typeof title !== \"string\" || title.length === 0) throw new WorkflowError(\"phase() requires a non-empty title string\", \"INVALID_ARGUMENT\");\n\t\tthis.currentPhase = title;\n\t\tthis.observer.phase(title);\n\t}\n\t/** The `log(message)` hook: narration to observers. */\n\tlog(message) {\n\t\tif (typeof message !== \"string\") throw new WorkflowError(\"log() requires a message string\", \"INVALID_ARGUMENT\");\n\t\tthis.observer.log(message);\n\t}\n};\n//#endregion\n//#region packages/workflow/workflow-ptc/src/guest.ts\n/**\n* Run a workflow with one progress batch in flight. Drain progress before child\n* disposal and the terminal result; PTC and the host own cancellation and cleanup.\n* @param host - JSON callbacks owned by this workflow run.\n* @returns The script result after progress delivery; initialization failures reject.\n*/\nasync function runWorkflowGuest(host) {\n\tconst init = await host.begin({});\n\tlet queued = [];\n\tlet inFlight;\n\tlet progressError;\n\tconst flush = () => {\n\t\tif (inFlight !== void 0 || queued.length === 0) return;\n\t\tconst batch = queued;\n\t\tqueued = [];\n\t\tinFlight = host.progress(batch).then(() => {\n\t\t\tinFlight = void 0;\n\t\t\tflush();\n\t\t}, (error) => {\n\t\t\tprogressError = renderThrown(error);\n\t\t\tqueued = [];\n\t\t\tinFlight = void 0;\n\t\t});\n\t};\n\tconst send = (event) => {\n\t\tif (progressError !== void 0) return;\n\t\tqueued.push(event);\n\t\tflush();\n\t};\n\tconst drain = async () => {\n\t\twhile (inFlight !== void 0) await inFlight;\n\t};\n\tconst result = await new WorkflowExecution(init.meta, init.body, init.args, init.limits, {\n\t\tphase: (title) => {\n\t\t\tsend({\n\t\t\t\ttype: \"phase\",\n\t\t\t\ttitle\n\t\t\t});\n\t\t},\n\t\tlog: (message) => {\n\t\t\tsend({\n\t\t\t\ttype: \"log\",\n\t\t\t\tmessage\n\t\t\t});\n\t\t},\n\t\tagentStart: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-start\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t},\n\t\tagentEnd: (info) => {\n\t\t\tsend({\n\t\t\t\ttype: \"agent-end\",\n\t\t\t\tinfo\n\t\t\t});\n\t\t}\n\t}, { async startAgent(request) {\n\t\tconst { callId, childId } = await host.startChild(request);\n\t\tconst result = host.childResult({ callId });\n\t\tresult.catch(() => {});\n\t\treturn {\n\t\t\tid: childId,\n\t\t\tresult,\n\t\t\tasync dispose() {\n\t\t\t\tawait drain();\n\t\t\t\tawait host.disposeChild({ callId });\n\t\t\t}\n\t\t};\n\t} }).drive();\n\tawait drain();\n\treturn progressError === void 0 ? result : {\n\t\tvalue: null,\n\t\tstopReason: \"error\",\n\t\terror: progressError,\n\t\tagentsStarted: result.agentsStarted\n\t};\n}\n//#endregion\nexport { runWorkflowGuest };\n//# sourceURL=dsh-workflow-guest.js\n" From dc07e5a50dadcf03e3fb9e1b7c69bb2ba9550254 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 17:36:16 +0800 Subject: [PATCH 012/282] fix(tools): keep surrogate pairs intact when capping tool output Three tools capped the preview text they return with String.prototype.slice at a UTF-16 code-unit budget. A cut that lands inside a surrogate pair leaves a lone high surrogate, which is not well-formed text: a strict JSON reader rejects the durable Session log carrying it, and the preview shows a broken glyph. A lone surrogate retained in history has already blocked every later Messages request for one session. Centralize the cap in output-retention as truncateWithoutSplittingSurrogatePair, and use it from the persistent bash and pwsh shells and the string-replace editor. The cut drops the unpaired half instead of keeping it, so the retained text stays a prefix and is at most one code unit shorter than before. The new dependency edges and the shifted Config sources regenerate docs/module-graph and docs/config-catalog, including the committed translation pair. Refs #4817 --- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 6 ++-- docs/config-catalog.zh.md | 6 ++-- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 9 +++-- docs/module-graph.zh.md | 9 +++-- .../fs/tool-str-replace-editor/package.json | 2 ++ .../fs/tool-str-replace-editor/src/index.ts | 3 +- .../tests/tools.spec.ts | 15 +++++++++ .../fs/tool-str-replace-editor/tsconfig.json | 1 + .../shell/tool-bash-persistent/package.json | 2 ++ .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tool-bash-persistent/tests/tools.spec.ts | 18 +++++++++- .../shell/tool-bash-persistent/tsconfig.json | 1 + .../shell/tool-pwsh-persistent/package.json | 2 ++ .../shell/tool-pwsh-persistent/src/index.ts | 3 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 18 +++++++++- .../shell/tool-pwsh-persistent/tsconfig.json | 1 + .../util/output-retention/README.i18n.yaml | 4 +-- packages/util/output-retention/README.md | 4 +-- packages/util/output-retention/README.zh.md | 4 +-- packages/util/output-retention/src/index.ts | 17 ++++++++++ .../tests/output-retention.spec.ts | 33 +++++++++++++++++++ pnpm-lock.yaml | 9 +++++ 24 files changed, 151 insertions(+), 27 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 43f977e619..bbb66436dd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c46008dd88e51dc079d72e6605d06176c26bdea1 -config-catalog.zh.md: 7376476771902e1bbcba3ae4d6374b1087e14a72 +config-catalog.md: 6aa27aec967066deb217d1a065b093ba4f09f20f +config-catalog.zh.md: 7051e686c6081c5da65745335bbda2ddef81edce diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c46008dd88..6aa27aec96 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3249,7 +3249,7 @@ export interface Config { } ``` -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) @@ -3443,7 +3443,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:481`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3517,7 +3517,7 @@ export interface Config { } ``` -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7376476771..7051e686c6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3251,7 +3251,7 @@ export interface Config { } ``` -来源: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts) +来源: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts) @@ -3445,7 +3445,7 @@ export interface Config { } ``` -来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源: [`packages/shell/tool-pwsh-persistent/src/index.ts:481`](../packages/shell/tool-pwsh-persistent/src/index.ts) @@ -3519,7 +3519,7 @@ export interface Config { } ``` -来源: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts) +来源: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 235cd3fdef..12cbb59624 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e36487734f39545fee9b188d9443336ad7ed2e0e -module-graph.zh.md: 646aa21ea348ddf5e73d12b94247319cfe3768d6 +module-graph.md: 44e499d26af5753a260d6bc0802ef67adb6de475 +module-graph.zh.md: 8e95ddc3a53956c92194c3274484695dcf599619 diff --git a/docs/module-graph.md b/docs/module-graph.md index e36487734f..44e499d26a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -780,6 +780,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 @@ -929,10 +930,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 @@ -1540,7 +1543,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) | @@ -1572,8 +1575,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` | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 646aa21ea3..8e95ddc3a5 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -782,6 +782,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 @@ -931,10 +932,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 @@ -1542,7 +1545,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) | @@ -1574,8 +1577,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` | [`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) | diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index da5a7bc917..4119e284f9 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -43,6 +44,7 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index f77736685e..3542ee1770 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' @@ -33,7 +34,7 @@ Notes for using the \`str_replace\` command: function maybeTruncate(content: string, maxOutputChars: number): string { return content.length <= maxOutputChars ? content - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function codepointCompare(left: string, right: string): number { diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 1f893f37fe..2bd7633706 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -347,6 +347,21 @@ describe('tool-str-replace-editor', () => { .toContain('') }) + it('clips a view at a code-unit boundary without splitting a surrogate pair', async () => { + const wide = await setup({ maxOutputChars: 10_000 }) + const file = join(wide.root, 'emoji.txt') + await writeFile(file, `${'😀'.repeat(4)}tail`) + const untruncated = text(await call(wide.ctx, wide.owner, { command: 'view', path: file })) + // The cap lands on the high surrogate of the first emoji. + const splitAt = untruncated.indexOf('😀') + 1 + + const clipped = await setup({ maxOutputChars: splitAt }) + const rendered = text(await call(clipped.ctx, clipped.owner, { command: 'view', path: file })) + + expect(rendered.startsWith(`${untruncated.slice(0, splitAt - 1)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('matches canonical empty-line, range, and end-insert behavior', async () => { const { ctx, root, owner } = await setup() const empty = join(root, 'empty.txt') diff --git a/packages/fs/tool-str-replace-editor/tsconfig.json b/packages/fs/tool-str-replace-editor/tsconfig.json index b9dba81d5d..686cded3e0 100644 --- a/packages/fs/tool-str-replace-editor/tsconfig.json +++ b/packages/fs/tool-str-replace-editor/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/tools" }, { "path": "../fs" }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 7587e0be20..f3f65c754a 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index c248ca94b5..ff6aa0f5be 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -58,7 +59,7 @@ function maybeTruncate(content: string, maxOutputChars: number, incomplete = fal if (content.length <= maxOutputChars && !incomplete) return content return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function markers(): CommandMarkers { diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index a79d116556..cc962c4447 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -114,6 +114,7 @@ class StubPtySession implements TerminalBackendSession { pendingText = '' historyTruncated = false throwOnSend = false + largeOutput = 'x'.repeat(100) constructor(mode: StubMode) { this.mode = mode @@ -206,7 +207,7 @@ class StubPtySession implements TerminalBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } const commandOutput = this.mode === 'large' - ? 'x'.repeat(100) + ? this.largeOutput : this.mode === 'nonzero' ? '' : 'hello from stub' const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` @@ -404,6 +405,21 @@ describe('tool-bash-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('clips a split surrogate pair instead of leaving a lone half', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 10 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'large' + // The tenth code unit is the emoji's high surrogate, so the clip drops the + // unpaired half instead of emitting it. + session.largeOutput = `${'x'.repeat(9)}😀tail` + + const rendered = text(await call(ctx, owner, 'emoji')) + + expect(rendered.startsWith(`${'x'.repeat(9)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('waits for status digits after a torn completion marker', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/shell/tool-bash-persistent/tsconfig.json b/packages/shell/tool-bash-persistent/tsconfig.json index fe64fdde1f..e3b72276d1 100644 --- a/packages/shell/tool-bash-persistent/tsconfig.json +++ b/packages/shell/tool-bash-persistent/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 2d14f4f879..a1147eaf54 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -27,6 +27,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 34f8cf7732..0448ddc076 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { truncateWithoutSplittingSurrogatePair } from '@deepseek-ai/dsh-output-retention' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -58,7 +59,7 @@ function maybeTruncate(content: string, maxOutputChars: number, incomplete = fal if (content.length <= maxOutputChars && !incomplete) return content return content.length <= maxOutputChars ? content + TRUNCATED_MESSAGE - : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE + : truncateWithoutSplittingSurrogatePair(content, maxOutputChars) + TRUNCATED_MESSAGE } function markers(): CommandMarkers { diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index c260f818eb..67825d8b17 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -119,6 +119,7 @@ class StubTerminalSession implements TerminalBackendSession { pendingText = '' historyTruncated = false throwOnSend = false + largeOutput = 'x'.repeat(100) constructor(mode: StubMode) { this.mode = mode @@ -218,7 +219,7 @@ class StubTerminalSession implements TerminalBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } const commandOutput = this.mode === 'large' - ? 'x'.repeat(100) + ? this.largeOutput : this.mode === 'nonzero' ? '' : this.mode === 'prompt-collision' ? this.motd : 'hello from stub' @@ -455,6 +456,21 @@ describe('tool-pwsh-persistent', () => { expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) + it('clips a split surrogate pair instead of leaving a lone half', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 10 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'large' + // The tenth code unit is the emoji's high surrogate, so the clip drops the + // unpaired half instead of emitting it. + session.largeOutput = `${'x'.repeat(9)}😀tail` + + const rendered = text(await call(ctx, owner, 'emoji')) + + expect(rendered.startsWith(`${'x'.repeat(9)}`)).toBe(true) + expect(rendered).not.toContain('\uD83D') + }) + it('waits for status digits after a torn completion marker', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) await call(ctx, owner, 'warm up') diff --git a/packages/shell/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json index fe64fdde1f..e3b72276d1 100644 --- a/packages/shell/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../util/output-retention" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, { "path": "../../terminal/terminal" }, diff --git a/packages/util/output-retention/README.i18n.yaml b/packages/util/output-retention/README.i18n.yaml index 71736fe9bf..53c073815a 100644 --- a/packages/util/output-retention/README.i18n.yaml +++ b/packages/util/output-retention/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/output-retention/README.md -README.md: bb0902b959f298f9f7b0a64de23d48e29bfd75ad -README.zh.md: 7d96ba4da33a98b24eba53f9dd5a2f2c98b79f39 +README.md: ec16dd9626f2268a9a019373c3e135619e1ef2e5 +README.zh.md: 1ae37399224838d6b044dacc81a15983f82ff9c8 diff --git a/packages/util/output-retention/README.md b/packages/util/output-retention/README.md index bb0902b959..ec16dd9626 100644 --- a/packages/util/output-retention/README.md +++ b/packages/util/output-retention/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-output-retention` to cap the items or text a tool returns to a model while reporting what was omitted. `ItemRetainer` keeps an ordered head window and can report an exact omitted-item count; `TextRetainer` keeps head, tail, or head-and-tail byte windows without returning invalid UTF-8 cuts. `formatRetentionNotice` adds a consistent omission clause while each tool supplies its own recovery guidance. Grouping, line numbering, spill files, and provider errors remain tool responsibilities; consumers import this library directly rather than loading it through `cordis.yml`. +Use `dsh-output-retention` to cap the items or text a tool returns to a model while reporting what was omitted. `ItemRetainer` keeps an ordered head window and can report an exact omitted-item count; `TextRetainer` keeps head, tail, or head-and-tail byte windows without returning invalid UTF-8 cuts. `formatRetentionNotice` adds a consistent omission clause while each tool supplies its own recovery guidance. `truncateWithoutSplittingSurrogatePair` caps a character-budget preview without leaving a lone high surrogate at the cut. Grouping, line numbering, spill files, and provider errors remain tool responsibilities; consumers import this library directly rather than loading it through `cordis.yml`. ## Table of Contents @@ -104,7 +104,7 @@ The library is built on one separation: it owns the mechanical question of what | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, and `formatRetentionNotice` | +| [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, `formatRetentionNotice`, and `truncateWithoutSplittingSurrogatePair` | | — | No runtime invariant companion is published; this pure utility owns no event stream or mutable runtime data; its value algebra is enforced by unit tests. | ### Two retainers, two resource models diff --git a/packages/util/output-retention/README.zh.md b/packages/util/output-retention/README.zh.md index 7d96ba4da3..1ae3739922 100644 --- a/packages/util/output-retention/README.zh.md +++ b/packages/util/output-retention/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -使用 `dsh-output-retention` 限制工具返回给模型的项或文本量,并报告省略了什么。`ItemRetainer` 保留有序的头部窗口,并可报告精确的省略项数;`TextRetainer` 保留 head、tail 或 head-and-tail 字节窗口,且不会返回因切割而无效的 UTF-8。`formatRetentionNotice` 添加一致的省略子句,各工具则提供自己的恢复指引。分组、行号、spill 文件与提供方错误仍归工具负责;消费方直接导入本库,而不通过 `cordis.yml` 加载。 +使用 `dsh-output-retention` 限制工具返回给模型的项或文本量,并报告省略了什么。`ItemRetainer` 保留有序的头部窗口,并可报告精确的省略项数;`TextRetainer` 保留 head、tail 或 head-and-tail 字节窗口,且不会返回因切割而无效的 UTF-8。`formatRetentionNotice` 添加一致的省略子句,各工具则提供自己的恢复指引。`truncateWithoutSplittingSurrogatePair` 按字符预算截断预览时,不会在切口留下孤立的高位代理项。分组、行号、spill 文件与提供方错误仍归工具负责;消费方直接导入本库,而不通过 `cordis.yml` 加载。 ## 目录 @@ -104,7 +104,7 @@ const footer = formatRetentionNotice( | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted` 与 `formatRetentionNotice` | +| [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted`、`formatRetentionNotice` 与 `truncateWithoutSplittingSurrogatePair` | | — | 不发布运行时不变式伴生入口;这个纯工具不拥有事件流或可变运行时数据;其值代数由单元测试保证。 | ### 两个 retainer,两种资源模型 diff --git a/packages/util/output-retention/src/index.ts b/packages/util/output-retention/src/index.ts index 5091c16374..3c2ba40e43 100644 --- a/packages/util/output-retention/src/index.ts +++ b/packages/util/output-retention/src/index.ts @@ -441,3 +441,20 @@ export function formatRetentionNotice( .filter(part => part.length > 0) .join(' ') } + +/** + * Cap `text` at `maxChars` UTF-16 code units without ending in a lone high + * surrogate: a cut that lands inside a surrogate pair drops the unpaired half, + * so the result is one code unit shorter than the cap. An unpaired surrogate is + * not well-formed text — a strict JSON reader rejects a durable Session log that + * carries one. + * + * @param text The text to cap. + * @param maxChars The maximum number of UTF-16 code units to retain. + * @returns A prefix of `text` no longer than `maxChars`, never ending in a lone high surrogate. + */ +export function truncateWithoutSplittingSurrogatePair(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + const capped = text.slice(0, maxChars) + return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped +} diff --git a/packages/util/output-retention/tests/output-retention.spec.ts b/packages/util/output-retention/tests/output-retention.spec.ts index dfad8eb251..e1f5c93d92 100644 --- a/packages/util/output-retention/tests/output-retention.spec.ts +++ b/packages/util/output-retention/tests/output-retention.spec.ts @@ -6,6 +6,7 @@ import { type Omitted, type RetentionNotice, TextRetainer, + truncateWithoutSplittingSurrogatePair, } from '@deepseek-ai/dsh-output-retention' /** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */ @@ -374,3 +375,35 @@ describe('formatRetentionNotice', () => { expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.') }) }) + +describe('truncateWithoutSplittingSurrogatePair', () => { + it('caps at maxChars UTF-16 code units', () => { + expect(truncateWithoutSplittingSurrogatePair('abcdef', 3)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('abcdef', 0)).toBe('') + expect(truncateWithoutSplittingSurrogatePair('abcdef', 1)).toBe('a') + }) + + it('returns text within the cap unchanged', () => { + expect(truncateWithoutSplittingSurrogatePair('abc', 3)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('abc', 9)).toBe('abc') + expect(truncateWithoutSplittingSurrogatePair('', 0)).toBe('') + }) + + it('drops the unpaired half when the cap lands inside a surrogate pair', () => { + // '😀' is two code units, so these caps would otherwise keep its high surrogate alone. + expect(truncateWithoutSplittingSurrogatePair('ab😀cd', 3)).toBe('ab') + expect(truncateWithoutSplittingSurrogatePair('ab😀cd', 4)).toBe('ab😀') + expect(truncateWithoutSplittingSurrogatePair('😀😀', 1)).toBe('') + expect(truncateWithoutSplittingSurrogatePair('😀😀', 3)).toBe('😀') + }) + + it('never ends in a lone high surrogate at any cap', () => { + const text = 'a😀b😀c' + for (let maxChars = 0; maxChars <= text.length; maxChars++) { + const capped = truncateWithoutSplittingSurrogatePair(text, maxChars) + expect(capped.length).toBeLessThanOrEqual(maxChars) + expect(capped).not.toMatch(/[\uD800-\uDBFF]$/) + expect(capped).toBe(text.slice(0, capped.length)) + } + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5c3824f8c..83a6919e54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7996,6 +7996,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -10792,6 +10795,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox @@ -10914,6 +10920,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../pwsh-local From b0ad792e6bcf4ee451f919347287ff24fefbfb49 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Mon, 21 Sep 2026 19:14:08 +0800 Subject: [PATCH 013/282] fix(web): keep dedicated previews and the read-card lang domain The shared code-language table must not claim a suffix a dedicated viewer declares, and the Host read card must keep the `lang` values it already persisted. - Drop `csv` from the shared table: Code registers before Spreadsheet, so the earlier body would take `.csv` and the browser snapshot gate fails. - Add `readLangHintForPath`, the read card's legacy projection over the shared table: the 43 pre-unification suffixes keep their old short ids byte for byte, new suffixes use the canonical id. - Add an ownership regression test over the full preview registry that turns red when `csv` returns, plus byte-level and alias-normalized tests for the read projection. --- .../ui-primitives/src/markdown/highlight.ts | 11 ++- .../tests/code-highlighting.client.spec.tsx | 24 ++++-- .../tests/apply.client.spec.ts | 32 +++++++- packages/fs/tool-fs/src/read-render.ts | 15 ++-- packages/fs/tool-fs/tests/read-render.spec.ts | 75 +++++++++++++++---- packages/fs/tool-fs/tests/tools.spec.ts | 4 +- packages/util/code-language/README.i18n.yaml | 4 +- packages/util/code-language/README.md | 4 +- packages/util/code-language/README.zh.md | 4 +- packages/util/code-language/src/index.ts | 60 +++++++++++++-- .../code-language/tests/code-language.spec.ts | 49 +++++++++++- 11 files changed, 238 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 989d22a711..aece48f580 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -230,13 +230,22 @@ const LANG_ALIASES = new Map([ ['gradle', 'groovy'], ]) +/** + * Resolve a language hint to the grammar id {@link LANG_ALIASES} selects. + * @param lang - Language hint from a code surface: a shared canonical id or a legacy read-card hint. + * @returns The resolved grammar id, or `undefined` when the table aliases no grammar. + */ +export function grammarForHint(lang: string | undefined): string | undefined { + return lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase()) +} + /** * Whether a language hint can use the shared syntax highlighter. * @param lang - Language hint from a code surface. * @returns Whether the hint resolves to a supported grammar. */ export function supportsHighlighting(lang: string | undefined): boolean { - return lang !== undefined && LANG_ALIASES.has(lang.toLowerCase()) + return grammarForHint(lang) !== undefined } /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ diff --git a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx index c8fdde9199..eb8c398992 100644 --- a/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-highlighting.client.spec.tsx @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, renderHook, waitFor } from '@testing-library/react' -import { languageForPath as sharedLanguageForPath } from '@deepseek-ai/dsh-util-code-language' +import { languageForPath as sharedLanguageForPath, readLangHintForPath } from '@deepseek-ai/dsh-util-code-language' import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts' -import { supportsHighlighting } from '../src/markdown/highlight.ts' +import { grammarForHint, supportsHighlighting } from '../src/markdown/highlight.ts' afterEach(cleanup) @@ -28,7 +28,7 @@ describe('code highlighting', () => { it.each([ ['build.bat', 'bat'], ['build.cmd', 'bat'], ['deploy.ps1', 'powershell'], ['module.psm1', 'powershell'], ['config.fish', 'fish'], ['app.properties', 'ini'], - ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['app.conf', 'ini'], ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], ['guide.rst', 'rst'], ['paper.tex', 'latex'], ['style.sty', 'latex'], ['doc.cls', 'latex'], ['refs.bib', 'bibtex'], ['Info.plist', 'xml'], ['logo.svg', 'xml'], ['manual.adoc', 'asciidoc'], @@ -46,11 +46,25 @@ describe('code highlighting', () => { }) it('resolves through the one shared extension table, never a local copy', () => { - // A second table re-introduced here fails this identity assertion before it - // can drift from the read card's file-extension hints. + // A second canonical table re-introduced here fails this identity assertion + // before it can drift from the shared extension table. expect(languageForPath).toBe(sharedLanguageForPath) }) + it('reaches one grammar from the shared canonical id and the read card legacy hint', () => { + // The read card persists the old short ids while this surface reads the + // canonical table directly; both must select the same grammar, so a + // persisted hint can never render differently from this surface's own + // selection. This is the alias-normalized check the identity assertion + // cannot make now that the read projection intentionally differs. + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + const path = `file.${extension}` + const readHint = readLangHintForPath(path) + expect(readHint, extension).toBeDefined() + expect(grammarForHint(readHint), extension).toBe(grammarForHint(languageForPath(path))) + } + }) + it('keeps every registered suffix highlightable by the shared primitive', () => { expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length) for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index 79963d3c42..669c85818c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -77,7 +77,7 @@ async function boot() { const fiber = ctx.plugin({ inject: [...inject], apply }) onTestFinished(async () => { await fiber.dispose() }) await fiber.await() - return { tabs, registered, dictionaries, fiber, read, readBytes } + return { ctx, tabs, registered, dictionaries, fiber, read, readBytes } } describe('ui-sidebar-documentpreview apply', () => { @@ -114,6 +114,36 @@ describe('ui-sidebar-documentpreview apply', () => { expect(dictionaries.size).toBe(0) }) + it('leaves every declared suffix with its owning preview and never lets the shared highlighter claim a later viewer\'s suffix', async () => { + const { ctx } = await boot() + const previews = ctx.get('documentPreviews') + if (previews === undefined) throw new Error('documentPreviews was not provided') + // apply() registers in production order: text, markdown, html, image, pdf, + // code, office, excel. The shared Code body is the generic highlighter, so + // where it collides with an EARLIER dedicated body the earlier body keeps the + // suffix; any other collision means a later dedicated viewer lost a suffix it + // must render. Listing csv under Code makes Spreadsheet the loser. + const sharedHighlighter = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code' + const owners = new Map() + for (const definition of previews.getSnapshot()) { + for (const extension of new Set([...definition.extensions, ...definition.binaryExtensions ?? []])) { + const winner = previews.candidates(`file.${extension}`)[0] + const earlier = owners.get(extension) + if (earlier === undefined) { + expect(winner?.id, `${definition.id} declares .${extension}`).toBe(definition.id) + } else { + expect(winner?.id, `.${extension} must stay with the earlier ${earlier}`).toBe(earlier) + expect(definition.id, `later body ${definition.id} must not collide on .${extension}`).toBe(sharedHighlighter) + } + owners.set(extension, earlier ?? definition.id) + } + } + // Delimited spreadsheets stay with the Spreadsheet viewer, not the Code body. + const excel = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/excel' + expect(previews.candidates('table.csv')[0]?.id).toBe(excel) + expect(previews.candidates('table.tsv')[0]?.id).toBe(excel) + }) + it('injects paged and byte Remote reads independently of resource metadata', async () => { const { registered, read, readBytes } = await boot() const registration = registered.find(entry => entry.component === TextPreview) diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 7862a4366f..79301214ad 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -170,14 +170,17 @@ ${body} } /** - * Derive a syntax-highlighting language id from a read path's file extension via - * the extension table shared with the Client code surfaces; see - * `@deepseek-ai/dsh-util-code-language` for the table and the path rules (both - * separators, a leading dot as the extension separator, and prototype-key safety). + * Derive the persisted `lang` hint from a read path's file extension. The shared + * table in `@deepseek-ai/dsh-util-code-language` owns the recognized suffixes and + * the path rules (both separators, a leading dot as the extension separator, and + * prototype-key safety); `readLangHintForPath` projects the read card's + * historical short ids over it, so a suffix the pre-unification read table + * recognized keeps its byte-identical old value while a newly added suffix uses + * the canonical id. * @param path - the model-facing path the read reported. - * @returns the language id, or `undefined` when the extension maps to none. + * @returns the persisted language hint, or `undefined` when the extension maps to none. */ -export { languageForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' +export { readLangHintForPath as langFromPath } from '@deepseek-ai/dsh-util-code-language' /** * The `read` tool's private `tool/result` `meta` payload: the structured diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 2e364b8e74..14a22083c0 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { languageForPath } from '@deepseek-ai/dsh-util-code-language' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -118,30 +118,77 @@ describe('buildWindow', () => { }) }) +/** + * The pre-unification read table's extension-to-hint mapping, transcribed + * verbatim. A read card persisted one of these strings, so changing any value + * changes replay-visible output for an already-recorded session. + */ +const MASTER_READ_LANG_BY_EXTENSION: Readonly> = { + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +} + describe('langFromPath', () => { - it('resolves through the one shared extension table, never a local copy', () => { - // The read card and the Client code surfaces must agree on every path; a - // local table re-introduced on either side makes one of these identity - // assertions fail before the two can drift in language hints. - expect(langFromPath).toBe(languageForPath) - for (const path of ['src/a.ts', 'conf.yml', 'README.md', 'build.PS1', 'C:\\src\\main.rs']) { - expect(langFromPath(path), path).toBe(languageForPath(path)) + it('keeps every pre-unification suffix byte-identical to the old persisted hint', () => { + expect(Object.keys(MASTER_READ_LANG_BY_EXTENSION)).toHaveLength(43) + for (const [extension, hint] of Object.entries(MASTER_READ_LANG_BY_EXTENSION)) { + expect(langFromPath(`file.${extension}`), extension).toBe(hint) } }) - it('maps a known extension to its canonical language id, case-insensitively', () => { - expect(langFromPath('src/a.ts')).toBe('typescript') - expect(langFromPath('src/a.TSX')).toBe('typescript') - expect(langFromPath('/abs/module.mjs')).toBe('javascript') + it('uses the shared canonical id for every suffix the old read table did not know', () => { + // The legacy projection is keyed by exactly the old extensions, so a suffix + // the shared table adds later must fall through to its canonical id instead + // of inheriting a stale short hint. + const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION)) + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + if (legacy.has(extension)) continue + expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`)) + } + }) + + it('gives a newly added suffix its shared canonical id', () => { + // These suffixes were undefined in the old build, so they are free to carry + // the canonical grammar id instead of a short hint. + expect(langFromPath('build.ps1')).toBe('powershell') + expect(langFromPath('deploy.bat')).toBe('bat') + expect(langFromPath('.env')).toBe('dotenv') + expect(langFromPath('server.log')).toBe('log') + expect(langFromPath('message.proto')).toBe('proto') + expect(langFromPath('infra.tf')).toBe('hcl') + expect(langFromPath('paper.tex')).toBe('latex') + }) + + it('maps a known extension to its old short hint, case-insensitively', () => { + expect(langFromPath('src/a.ts')).toBe('ts') + expect(langFromPath('src/a.TSX')).toBe('tsx') + expect(langFromPath('/abs/module.mjs')).toBe('js') expect(langFromPath('conf.yml')).toBe('yaml') - expect(langFromPath('README.md')).toBe('markdown') + expect(langFromPath('README.md')).toBe('md') + }) + + it('keeps the canonical ids the Client code surfaces use out of the persisted hint', () => { + // The Client reads the shared table directly; only this projection owns the + // persisted value, so the two intentionally differ for the old suffixes. + expect(languageForPath('src/a.ts')).toBe('typescript') + expect(languageForPath('README.md')).toBe('markdown') + expect(langFromPath('src/a.ts')).not.toBe(languageForPath('src/a.ts')) }) it('reads the extension after the last path segment and last dot', () => { expect(langFromPath('a.py.bak')).toBeUndefined() expect(langFromPath('archive.tar.gz')).toBeUndefined() expect(langFromPath('/dir.py/plain')).toBeUndefined() - expect(langFromPath('C:\\src\\main.rs')).toBe('rust') + expect(langFromPath('C:\\src\\main.rs')).toBe('rs') }) it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index ac70df7015..28613a2920 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -352,7 +352,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', }) const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result) expect(view).toEqual({ @@ -361,7 +361,7 @@ describe('read tool', () => { offset: 1, lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }], totalLines: 2, - lang: 'typescript', + lang: 'ts', content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }], }) }) diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml index e9789fe208..0c35649bcd 100644 --- a/packages/util/code-language/README.i18n.yaml +++ b/packages/util/code-language/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/code-language/README.md -README.md: a65c28cc11e5aa112b5450b9ec42b23dce764c9d -README.zh.md: cc15751853a8ab8b796bf16f989ee001d7347d9e +README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967 +README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md index a65c28cc11..adb04ba328 100644 --- a/packages/util/code-language/README.md +++ b/packages/util/code-language/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a filename or path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the extension separator (`.env` resolves to `dotenv`). `CODE_HIGHLIGHT_EXTENSIONS` lists every recognized suffix for a preview registry. The package is browser-safe, has no Cordis service or runtime state, and leaves the actual tokenization to the Client highlighter. +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. ## Table of Contents @@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. -The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. ----- diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md index cc15751853..c2ced098f4 100644 --- a/packages/util/code-language/README.zh.md +++ b/packages/util/code-language/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-library" 表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 -该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 ----- diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts index 09aac2143f..1f7743fee2 100644 --- a/packages/util/code-language/src/index.ts +++ b/packages/util/code-language/src/index.ts @@ -5,6 +5,9 @@ * highlighter's alias table resolves, so a language returned here reaches * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or * one naming a language the highlighter does not register, renders as plain text. + * The read card predates the shared table, so {@link readLangHintForPath} projects + * its historical short ids over this table for the suffixes the old read table + * already recognized, keeping those persisted values byte-identical. * @module @deepseek-ai/dsh-util-code-language */ @@ -17,7 +20,9 @@ * grammar does not exist map to the nearest available grammar * (`properties` to `ini`, whose registration carries the `properties` alias) or * stay unlisted. Certificate and lock extensions (`pem`, `crt`, `key`, `cer`, - * `lock`) intentionally stay unlisted. + * `lock`) stay unlisted, and `csv` is deliberately absent: the Spreadsheet + * preview declares it and must keep it, so listing it here would let the earlier + * Code registration take the suffix. */ const LANGUAGE_EXTENSIONS: Readonly> = { typescript: ['ts', 'tsx', 'mts', 'cts'], @@ -41,7 +46,6 @@ const LANGUAGE_EXTENSIONS: Readonly> = { ini: ['ini', 'conf', 'cfg', 'properties'], dotenv: ['env'], log: ['log'], - csv: ['csv'], diff: ['diff', 'patch'], http: ['http'], markdown: ['md', 'markdown'], @@ -95,6 +99,34 @@ const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS) /** Every recognized filename suffix, one entry each; a document-preview registry uses it to claim Code-rendered bodies. */ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()] +/** + * The read card's historical short language hints for the suffixes its + * pre-unification table recognized. Keys are shared-table extensions, so the + * recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve + * the old persisted `lang` strings byte for byte. An extension absent here but + * present in the shared table is new and uses its canonical id. + */ +const READ_LANG_BY_EXTENSION = new Map(Object.entries({ + ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', + js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js', + json: 'json', jsonc: 'json', + py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java', + c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp', + cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php', + sh: 'sh', bash: 'sh', zsh: 'sh', + yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini', + md: 'md', markdown: 'md', mdx: 'mdx', + html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less', + sql: 'sql', xml: 'xml', lua: 'lua', +})) + +function extensionForPath(path: string): string | undefined { + const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + const base = path.slice(slash + 1) + const dot = base.lastIndexOf('.') + return dot < 0 ? undefined : base.slice(dot + 1).toLowerCase() +} + /** * Derive the syntax-highlighting language from a filename or path, case * insensitively. The extension is the text after the last dot of the final path @@ -106,9 +138,23 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys() * @returns the canonical language id, or `undefined` for an unrecognized or absent suffix. */ export function languageForPath(path: string): string | undefined { - const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - const base = path.slice(slash + 1) - const dot = base.lastIndexOf('.') - if (dot < 0) return undefined - return LANGUAGES.get(base.slice(dot + 1).toLowerCase()) + const extension = extensionForPath(path) + return extension === undefined ? undefined : LANGUAGES.get(extension) +} + +/** + * Derive the Host read card's persisted `lang` hint from a read path's + * extension. This is the legacy projection the read card writes: a suffix the + * pre-unification read table recognized keeps its old short id (`ts`, `md`, + * `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`, + * `.tf`, …) yields its canonical id. An unrecognized suffix stays + * `undefined`, so the card renders as plain text. + * @param path - the model-facing path the read reported. + * @returns the persisted language hint, or `undefined` when the extension maps to none. + */ +export function readLangHintForPath(path: string): string | undefined { + const extension = extensionForPath(path) + if (extension === undefined) return undefined + const canonical = LANGUAGES.get(extension) + return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical } diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts index 0ede43e840..f93681350e 100644 --- a/packages/util/code-language/tests/code-language.spec.ts +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '../src/index.ts' +import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, readLangHintForPath } from '../src/index.ts' describe('languageForPath', () => { it.each([ @@ -8,7 +8,7 @@ describe('languageForPath', () => { ['module.psm1', 'powershell'], ['data.psd1', 'powershell'], ['config.fish', 'fish'], // Config, data, and text extensions. ['app.properties', 'ini'], ['app.conf', 'ini'], ['app.cfg', 'ini'], - ['.env', 'dotenv'], ['server.log', 'log'], ['rows.csv', 'csv'], + ['.env', 'dotenv'], ['server.log', 'log'], ['change.diff', 'diff'], ['fix.patch', 'diff'], ['api.http', 'http'], ['notebook.ipynb', 'json'], // Documentation and markup extensions. @@ -79,6 +79,51 @@ describe('languageForPath', () => { expect(languageForPath(`secret.${extension}`), extension).toBeUndefined() } }) + + it('leaves delimited-spreadsheet suffixes to the Spreadsheet preview', () => { + // The Code preview registers before the Spreadsheet preview, so listing csv + // here would make Code the earlier candidate and steal the suffix from the + // viewer that must render it. + for (const extension of ['csv', 'tsv']) { + expect(languageForPath(`table.${extension}`), extension).toBeUndefined() + } + }) +}) + +describe('readLangHintForPath', () => { + it('keeps the read card pre-unification short hints for the old suffixes', () => { + expect(readLangHintForPath('src/a.ts')).toBe('ts') + expect(readLangHintForPath('src/a.tsx')).toBe('tsx') + expect(readLangHintForPath('src/a.mts')).toBe('ts') + expect(readLangHintForPath('src/a.cts')).toBe('ts') + expect(readLangHintForPath('src/a.js')).toBe('js') + expect(readLangHintForPath('src/a.jsx')).toBe('jsx') + expect(readLangHintForPath('src/a.mjs')).toBe('js') + expect(readLangHintForPath('src/a.cjs')).toBe('js') + expect(readLangHintForPath('src/a.jsonc')).toBe('json') + expect(readLangHintForPath('src/a.cc')).toBe('cpp') + expect(readLangHintForPath('src/a.hpp')).toBe('cpp') + expect(readLangHintForPath('src/a.sh')).toBe('sh') + expect(readLangHintForPath('src/a.kt')).toBe('kotlin') + expect(readLangHintForPath('README.md')).toBe('md') + expect(readLangHintForPath('README.markdown')).toBe('md') + expect(readLangHintForPath('page.htm')).toBe('html') + }) + + it('uses the canonical id for a suffix added after the old read table', () => { + expect(readLangHintForPath('build.ps1')).toBe('powershell') + expect(readLangHintForPath('.env')).toBe('dotenv') + expect(readLangHintForPath('infra.tf')).toBe('hcl') + expect(readLangHintForPath('paper.tex')).toBe('latex') + expect(readLangHintForPath('server.log')).toBe('log') + expect(readLangHintForPath('message.proto')).toBe('proto') + }) + + it('returns undefined exactly when the shared table does not recognize the suffix', () => { + for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) { + expect(readLangHintForPath(path), path).toBeUndefined() + } + }) }) describe('CODE_HIGHLIGHT_EXTENSIONS', () => { From 19b7de31a6c65387739fe07094b41d976f1d3bae Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 12:21:19 +0800 Subject: [PATCH 014/282] refactor(tool-fs): persist the read language hint as a short id --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/highlight.ts | 1 + packages/fs/tool-fs/src/read-render.ts | 2 +- packages/fs/tool-fs/tests/read-render.spec.ts | 37 ++++---- packages/util/code-language/README.i18n.yaml | 4 +- packages/util/code-language/README.md | 4 +- packages/util/code-language/README.zh.md | 4 +- packages/util/code-language/src/index.ts | 92 +++++++++++++++++-- .../code-language/tests/code-language.spec.ts | 41 ++++++++- 11 files changed, 149 insertions(+), 44 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e25e0e95fc..c222e01007 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 6cb650eabbb237a0e57bdc0fc12547397458a245 -README.zh.md: f37fc9727ebdce9a0c185931fd74b4488a3b19b8 +README.md: f0ae8eaa30043a23fba72eb403921a38e87ee3a5 +README.zh.md: 0c97794567cea572804051184473e6695ecc46a0 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 6cb650eabb..f0ae8eaa30 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -64,7 +64,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | -| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card. | +| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The lazy line-token highlighter shared by code preview and diff review. The filename grammar selection is re-exported from `@deepseek-ai/dsh-util-code-language`, the single extension table also behind the read card's persisted short-id `lang` hint. | Four pairs are easy to confuse: diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index f37fc9727e..0c97794567 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -64,7 +64,7 @@ kind: "package-library" | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | | `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | -| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片背后的同一张扩展名表。 | +| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的惰性逐行 token 高亮。文件名 grammar 选择再导出自 `@deepseek-ai/dsh-util-code-language`,即 read 卡片持久化短 id `lang` 提示背后的同一张扩展名表。 | 有四组容易混淆: diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index aece48f580..9cd05d7f5d 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -208,6 +208,7 @@ const LANG_ALIASES = new Map([ ['pl', 'perl'], ['pm', 'perl'], ['verilog', 'verilog'], + ['v', 'verilog'], ['system-verilog', 'system-verilog'], ['systemverilog', 'system-verilog'], ['sv', 'system-verilog'], diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 79301214ad..23a27a210f 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -176,7 +176,7 @@ ${body} * prototype-key safety); `readLangHintForPath` projects the read card's * historical short ids over it, so a suffix the pre-unification read table * recognized keeps its byte-identical old value while a newly added suffix uses - * the canonical id. + * its language's short name. * @param path - the model-facing path the read reported. * @returns the persisted language hint, or `undefined` when the extension maps to none. */ diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 14a22083c0..5f679caff9 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath } from '@deepseek-ai/dsh-util-code-language' +import { languageForPath } from '@deepseek-ai/dsh-util-code-language' import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts' import type { ReadWindow } from '../src/read-render.ts' @@ -145,27 +145,26 @@ describe('langFromPath', () => { } }) - it('uses the shared canonical id for every suffix the old read table did not know', () => { - // The legacy projection is keyed by exactly the old extensions, so a suffix - // the shared table adds later must fall through to its canonical id instead - // of inheriting a stale short hint. - const legacy = new Set(Object.keys(MASTER_READ_LANG_BY_EXTENSION)) - for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { - if (legacy.has(extension)) continue - expect(langFromPath(`file.${extension}`), extension).toBe(languageForPath(`file.${extension}`)) - } - }) - - it('gives a newly added suffix its shared canonical id', () => { - // These suffixes were undefined in the old build, so they are free to carry - // the canonical grammar id instead of a short hint. - expect(langFromPath('build.ps1')).toBe('powershell') + it('gives a suffix added after the old read table the language short id', () => { + // A suffix the old read table did not know used to persist the canonical + // grammar id; it now persists the language's short name. + expect(langFromPath('build.ps1')).toBe('ps1') expect(langFromPath('deploy.bat')).toBe('bat') - expect(langFromPath('.env')).toBe('dotenv') + expect(langFromPath('.env')).toBe('env') expect(langFromPath('server.log')).toBe('log') expect(langFromPath('message.proto')).toBe('proto') - expect(langFromPath('infra.tf')).toBe('hcl') - expect(langFromPath('paper.tex')).toBe('latex') + expect(langFromPath('infra.tf')).toBe('tf') + expect(langFromPath('paper.tex')).toBe('tex') + expect(langFromPath('model.jl')).toBe('jl') + expect(langFromPath('top.v')).toBe('v') + expect(langFromPath('build.gradle')).toBe('gradle') + // A later suffix of an already-known language follows that language's short name. + expect(langFromPath('app.conf')).toBe('ini') + expect(langFromPath('task.rake')).toBe('rb') + expect(langFromPath('events.jsonl')).toBe('json') + expect(langFromPath('page.xhtml')).toBe('html') + expect(langFromPath('logo.svg')).toBe('xml') + expect(langFromPath('notebook.ipynb')).toBe('json') }) it('maps a known extension to its old short hint, case-insensitively', () => { diff --git a/packages/util/code-language/README.i18n.yaml b/packages/util/code-language/README.i18n.yaml index 0c35649bcd..4d0ca4561a 100644 --- a/packages/util/code-language/README.i18n.yaml +++ b/packages/util/code-language/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/code-language/README.md -README.md: adb04ba3283e38d1b708f1831ffd41c0f08ce967 -README.zh.md: c2ced098f48946abdc4affae6b7b3fa09bd439fb +README.md: cf3fbd0e95d689440c10ce3522616a20b86552a4 +README.zh.md: 7476f5a02b07356d602e96f00927944718b572df diff --git a/packages/util/code-language/README.md b/packages/util/code-language/README.md index adb04ba328..cf3fbd0e95 100644 --- a/packages/util/code-language/README.md +++ b/packages/util/code-language/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's older short ids over the table so previously persisted values stay byte-identical. The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. +The repository's one file-extension to syntax-highlighting language table, shared by the Client's document Code preview and diff review and by the Host read tool's persisted `lang` hint. `languageForPath` maps a path to a canonical grammar id case-insensitively, recognizing both path separators and treating a leading dot as the separator (`.env` is `dotenv`); `CODE_HIGHLIGHT_EXTENSIONS` lists every suffix for a preview registry. `readLangHintForPath` projects the read card's persisted `lang` onto short ids: the suffixes the old read table recognized keep their historical value byte-identical, and every later suffix uses its language's short name (`powershell` to `ps1`, `hcl` to `tf`). The package is browser-safe, stateless, and leaves tokenization to the Client highlighter. ## Table of Contents @@ -24,7 +24,7 @@ The repository's one file-extension to syntax-highlighting language table, share The table lives in [`src/index.ts`](src/index.ts). Each key is a canonical language id — the grammar id the Client highlighter resolves — and each value is the lowercase extensions without the dot that select it. `languageForPath(path)` takes the text after the last dot of the final path segment, lowercases it, and looks it up in a `Map`; a `Map` keeps a filename such as `foo.constructor` from resolving an `Object.prototype` member. An unlisted dotfile (`.gitignore`), an absent extension, a trailing dot, and an unknown suffix all return `undefined`, which every consumer renders as plain text; a leading dot is still the separator, so `.env` resolves to `dotenv`. Both `/` and `\` separate path segments, so a Windows path resolves like a POSIX one. -The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns its canonical id, and an unrecognized suffix returns `undefined`. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. +The set is curated for common source, config, script, data, and markup extensions rather than mirroring a full language registry. Extensions with no matching grammar map to the nearest one (`properties` to `ini`, whose registration carries the `properties` alias); certificate and lock extensions (`pem`, `crt`, `key`, `cer`, `lock`) stay unlisted, and `csv` stays unlisted because the Spreadsheet preview declares the suffix and must keep it, so the earlier Code registration cannot claim it. `readLangHintForPath` is a projection over this table: a suffix the pre-unification read table recognized returns its old short id, a suffix present only here returns the language's short name (`powershell` to `ps1`, `hcl` to `tf`), and an unrecognized suffix returns `undefined` — so the persisted field holds one style, a short id, never a canonical grammar id. A consumer that needs the Client highlighter to actually tokenize a language still depends on that grammar being registered there — an id without a loaded grammar renders as plain text rather than failing. ----- diff --git a/packages/util/code-language/README.zh.md b/packages/util/code-language/README.zh.md index c2ced098f4..7476f5a02b 100644 --- a/packages/util/code-language/README.zh.md +++ b/packages/util/code-language/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片保留统一前的短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀则使用规范 id。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 +本仓库唯一的「文件扩展名 → 语法高亮语言」表,供 Client 的文档 Code 预览、diff 审阅与 Host read 工具持久化的 `lang` 提示共同使用。`languageForPath` 以大小写不敏感的方式把文件名或路径映射为规范化 grammar id,识别两种路径分隔符,并把前导点视为扩展名分隔符(`.env` 解析为 `dotenv`)。`CODE_HIGHLIGHT_EXTENSIONS` 列出全部已识别后缀,供预览注册使用。Host read 卡片持久化的 `lang` 一律是短 id:`readLangHintForPath` 在同一张表上做投影——旧 read 表已识别的后缀保持逐字节相同的旧值,新增后缀使用该语言的短名(`powershell`→`ps1`、`hcl`→`tf`)。该包可在浏览器使用,不提供 Cordis service,也不持有运行时状态;真正的分词仍由 Client 高亮器负责。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-library" 表位于 [`src/index.ts`](src/index.ts)。每个键是规范化语言 id——即 Client 高亮器解析的 grammar id;每个值是选中它的、不含点的小写扩展名。`languageForPath(path)` 取路径最后一段的最后一个点之后的文本、转小写后在 `Map` 中查找;使用 `Map` 可避免 `foo.constructor` 这类文件名解析到 `Object.prototype` 成员。未列入表的 dotfile(`.gitignore`)、无扩展名、结尾点、未知后缀都返回 `undefined`,各消费方一律按纯文本渲染;前导点仍算分隔符,因此 `.env` 解析为 `dotenv`。`/` 与 `\` 都算路径分隔符,因此 Windows 路径与 POSIX 路径解析一致。 -该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 +该集合按常见源码、配置、脚本、数据与标记扩展名精选,并非完整语言登记表。没有对应 grammar 的扩展名映射到最接近的 grammar(`properties` 映射到 `ini`,该 grammar 自带 `properties` alias);证书与锁文件扩展名(`pem`、`crt`、`key`、`cer`、`lock`)保持不表,`csv` 同样保持不表:Spreadsheet 预览声明该后缀且必须保留,因此更早注册的 Code 不能抢占它。`readLangHintForPath` 在同一张表上做投影:旧 read 表已识别的后缀返回历史短 id,只有本表才知道的后缀返回该语言的短名(`powershell`→`ps1`、`hcl`→`tf`),无法识别的后缀返回 `undefined`——持久化字段因此只有一种风格,即短 id,绝不出现规范 grammar id。消费方若希望 Client 高亮器真正分词,仍取决于该 grammar 已在其中注册——没有加载 grammar 的 id 会按纯文本渲染,而不是报错。 ----- diff --git a/packages/util/code-language/src/index.ts b/packages/util/code-language/src/index.ts index 1f7743fee2..dd4a37115a 100644 --- a/packages/util/code-language/src/index.ts +++ b/packages/util/code-language/src/index.ts @@ -6,8 +6,9 @@ * `highlightLines`/`highlightToHtml` unchanged; a filename outside the table, or * one naming a language the highlighter does not register, renders as plain text. * The read card predates the shared table, so {@link readLangHintForPath} projects - * its historical short ids over this table for the suffixes the old read table - * already recognized, keeping those persisted values byte-identical. + * short ids over this table: the suffixes the old read table already recognized + * keep their historical values byte-identical, and every later suffix persists the + * canonical language's short name rather than its grammar id. * @module @deepseek-ai/dsh-util-code-language */ @@ -104,7 +105,8 @@ export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys() * pre-unification table recognized. Keys are shared-table extensions, so the * recognized set still derives from {@link LANGUAGE_EXTENSIONS}; values preserve * the old persisted `lang` strings byte for byte. An extension absent here but - * present in the shared table is new and uses its canonical id. + * present in the shared table falls back to {@link SHORT_BY_LANGUAGE}, so every + * persisted hint is a short id. */ const READ_LANG_BY_EXTENSION = new Map(Object.entries({ ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts', @@ -120,6 +122,78 @@ const READ_LANG_BY_EXTENSION = new Map(Object.entries({ sql: 'sql', xml: 'xml', lua: 'lua', })) +/** + * The short `lang` id the read card persists for each canonical language the + * pre-unification read table did not recognize. Keys are exactly the languages + * in {@link LANGUAGE_EXTENSIONS}: the 27 languages the old read table covered + * repeat their historical short id, and the remaining 33 shorten the grammar id + * (`powershell` to `ps1`, `hcl` to `tf`, `system-verilog` to `sv`). + * Keys are canonical ids, not untrusted extensions, so an object lookup cannot + * reach an `Object.prototype` member the way the extension tables can. + */ +const SHORT_BY_LANGUAGE: Readonly> = { + typescript: 'ts', + javascript: 'js', + shellscript: 'sh', + fish: 'fish', + json: 'json', + python: 'py', + ruby: 'rb', + go: 'go', + rust: 'rs', + java: 'java', + c: 'c', + cpp: 'cpp', + csharp: 'cs', + kotlin: 'kotlin', + swift: 'swift', + php: 'php', + yaml: 'yaml', + toml: 'toml', + ini: 'ini', + dotenv: 'env', + log: 'log', + diff: 'diff', + http: 'http', + markdown: 'md', + mdx: 'mdx', + rst: 'rst', + latex: 'tex', + bibtex: 'bib', + asciidoc: 'adoc', + html: 'html', + css: 'css', + scss: 'scss', + less: 'less', + sql: 'sql', + xml: 'xml', + lua: 'lua', + bat: 'bat', + powershell: 'ps1', + r: 'r', + julia: 'jl', + dart: 'dart', + scala: 'scala', + clojure: 'clj', + erlang: 'erl', + elixir: 'ex', + haskell: 'hs', + fsharp: 'fs', + vb: 'vb', + perl: 'pl', + verilog: 'v', + 'system-verilog': 'sv', + graphql: 'graphql', + proto: 'proto', + hcl: 'tf', + nix: 'nix', + vue: 'vue', + svelte: 'svelte', + make: 'make', + cmake: 'cmake', + groovy: 'gradle', +} + function extensionForPath(path: string): string | undefined { const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) const base = path.slice(slash + 1) @@ -144,11 +218,11 @@ export function languageForPath(path: string): string | undefined { /** * Derive the Host read card's persisted `lang` hint from a read path's - * extension. This is the legacy projection the read card writes: a suffix the - * pre-unification read table recognized keeps its old short id (`ts`, `md`, - * `cpp`, …), while a suffix only the shared table knows (`.ps1`, `.env`, - * `.tf`, …) yields its canonical id. An unrecognized suffix stays - * `undefined`, so the card renders as plain text. + * extension. The hint is always a short id: a suffix the pre-unification read + * table recognized keeps its old value (`ts`, `md`, `cpp`, …), while a suffix + * only the shared table knows (`.ps1`, `.env`, `.tf`, …) uses its language's + * short name (`ps1`, `env`, `tf`, …) rather than the canonical grammar id. An + * unrecognized suffix stays `undefined`, so the card renders as plain text. * @param path - the model-facing path the read reported. * @returns the persisted language hint, or `undefined` when the extension maps to none. */ @@ -156,5 +230,5 @@ export function readLangHintForPath(path: string): string | undefined { const extension = extensionForPath(path) if (extension === undefined) return undefined const canonical = LANGUAGES.get(extension) - return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? canonical + return canonical === undefined ? undefined : READ_LANG_BY_EXTENSION.get(extension) ?? SHORT_BY_LANGUAGE[canonical] } diff --git a/packages/util/code-language/tests/code-language.spec.ts b/packages/util/code-language/tests/code-language.spec.ts index f93681350e..d03b02d719 100644 --- a/packages/util/code-language/tests/code-language.spec.ts +++ b/packages/util/code-language/tests/code-language.spec.ts @@ -110,15 +110,46 @@ describe('readLangHintForPath', () => { expect(readLangHintForPath('page.htm')).toBe('html') }) - it('uses the canonical id for a suffix added after the old read table', () => { - expect(readLangHintForPath('build.ps1')).toBe('powershell') - expect(readLangHintForPath('.env')).toBe('dotenv') - expect(readLangHintForPath('infra.tf')).toBe('hcl') - expect(readLangHintForPath('paper.tex')).toBe('latex') + it('uses the language short id for a suffix added after the old read table', () => { + expect(readLangHintForPath('build.ps1')).toBe('ps1') + expect(readLangHintForPath('.env')).toBe('env') + expect(readLangHintForPath('infra.tf')).toBe('tf') + expect(readLangHintForPath('paper.tex')).toBe('tex') expect(readLangHintForPath('server.log')).toBe('log') expect(readLangHintForPath('message.proto')).toBe('proto') }) + // The short id each canonical language persists. Transcribed rather than + // imported so this test fails when a canonical grammar id is put back into the + // persisted value, which reading the implementation's own table would not catch. + const SHORT_BY_LANGUAGE: Readonly> = { + typescript: 'ts', javascript: 'js', shellscript: 'sh', fish: 'fish', json: 'json', + python: 'py', ruby: 'rb', go: 'go', rust: 'rs', java: 'java', c: 'c', cpp: 'cpp', + csharp: 'cs', kotlin: 'kotlin', swift: 'swift', php: 'php', yaml: 'yaml', + toml: 'toml', ini: 'ini', dotenv: 'env', log: 'log', diff: 'diff', http: 'http', + markdown: 'md', mdx: 'mdx', rst: 'rst', latex: 'tex', bibtex: 'bib', + asciidoc: 'adoc', html: 'html', css: 'css', scss: 'scss', less: 'less', sql: 'sql', + xml: 'xml', lua: 'lua', bat: 'bat', powershell: 'ps1', r: 'r', julia: 'jl', + dart: 'dart', scala: 'scala', clojure: 'clj', erlang: 'erl', elixir: 'ex', + haskell: 'hs', fsharp: 'fs', vb: 'vb', perl: 'pl', verilog: 'v', + 'system-verilog': 'sv', graphql: 'graphql', proto: 'proto', hcl: 'tf', nix: 'nix', + vue: 'vue', svelte: 'svelte', make: 'make', cmake: 'cmake', groovy: 'gradle', + } + + it('persists a short id for every suffix, never a canonical grammar id', () => { + // The persisted `lang` has one style: the historical short id for the + // suffixes the old read table knew, the language's short name otherwise. A + // result outside both sets is a canonical grammar id leaking back in + // (`powershell`, `dotenv`, `latex`, `hcl`). The old table's `tsx`/`jsx` + // hints are the only legacy values absent from the short-name table. + const allowed = new Set([...Object.values(SHORT_BY_LANGUAGE), 'tsx', 'jsx']) + for (const extension of CODE_HIGHLIGHT_EXTENSIONS) { + const hint = readLangHintForPath(`file.${extension}`) + expect(hint, extension).toBeDefined() + expect(allowed.has(hint ?? ''), extension).toBe(true) + } + }) + it('returns undefined exactly when the shared table does not recognize the suffix', () => { for (const path of ['.gitignore', '/etc/hosts', 'trailingdot.', 'data.unknownext', 'foo.constructor']) { expect(readLangHintForPath(path), path).toBeUndefined() From bca9cecc7d901de348b08ebf10b1012a527687c2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:28:10 +0800 Subject: [PATCH 015/282] fix(client): keep pointer focus silent through descendant and editable rings Pointer suppression previously cleared `outline-color` on the focused element alone, so a ring painted by a descendant, a pseudo-element, or a `box-shadow` stayed visible, and editable text controls lost the focus feedback they need on click. Ring colour now flows through `--dsw-focus-ring-color`, which component outlines and focus-ring shadows read alongside the brand token. Every ring that inherits the variable is suppressed together, and `body :focus-visible:not(:read-write)` limits the suppression to rings while editable controls keep their own feedback. Keyboard modality returns on Tab, Home, End, PageUp, or PageDown, and on focus reaching a different control after a non-composing key. IME composition keys, refocusing the same control, and window blur leave rings silent. The desktop preload menubar suppresses its own ring inside its shadow root, where the document-level attribute does not reach. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 4 +- .../2026-09-20-focus-ring-brand-primary.md | 30 +- .../2026-09-20-focus-ring-brand-primary.zh.md | 30 +- apps/desktop/src/preload-menu.ts | 3 +- apps/web/tests/focus-rings.e2e.ts | 357 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../src/client/AgentPresetSection.module.css | 6 +- .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +- .../src/client/ChangedFiles.module.css | 4 +- .../src/client/Deliverables.module.css | 2 +- .../src/client/DirectoryBrowser.module.css | 4 +- .../src/client/FeedbackDialog.module.css | 2 +- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +- .../src/client/PluginManagerPage.module.css | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 + packages/client/ui-primitives/README.zh.md | 6 + .../ui-primitives/src/Checkbox.module.css | 2 +- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +- .../src/RiskConfirmation.module.css | 2 +- .../ui-primitives/src/Switch.module.css | 2 +- .../ui-primitives/src/input-modality.ts | 47 ++- .../src/markdown/MarkdownText.module.css | 4 +- .../tests/input-modality.client.spec.ts | 87 ++++- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 8 +- .../src/client/PluginConfigForm.module.css | 2 +- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/fields.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 6 +- packages/client/ui-theme/README.zh.md | 6 +- packages/client/ui-theme/src/styles/focus.css | 24 +- .../tests/focus-styles.client.spec.ts | 66 ++++ .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 24 +- .../src/client/TrajectoryTimeline.module.css | 4 +- .../src/client/TrajectoryToolbar.module.css | 6 +- .../src/client/WorkflowRunPanel.module.css | 6 +- .../client/rows/WorkspaceBrowser.module.css | 2 +- tsconfig.host.json | 1 + 50 files changed, 669 insertions(+), 141 deletions(-) create mode 100644 apps/web/tests/focus-rings.e2e.ts create mode 100644 packages/client/ui-theme/tests/focus-styles.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml index 6707b51680..f30d9be553 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md -2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e -2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 +2026-09-20-focus-ring-brand-primary.md: 288f3722428a5dd52822c3fecbbaaea5c7c6310f +2026-09-20-focus-ring-brand-primary.zh.md: 792374b92cbd056bf181b79f8b22e1aec572d3bb diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md index 3c6c041ac0..288f372242 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -6,28 +6,38 @@ English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) ## Problem -Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. +Different explicit focus colours and browser-default outlines do not provide a consistent keyboard-focus cue. Browser defaults can also depend on the operating-system accent. + +Focus feedback has separate interaction and geometry constraints. A browser's `:focus-visible` heuristic can reveal a pointer-focused button after a key that does not move focus, while editable text controls need focus feedback on click. Containers that clip their contents can hide an outer ring. ## Decision -Every keyboard focus ring uses `--dsw-alias-brand-primary`. +[The theme's focus stylesheet](../../../../packages/client/ui-theme/src/styles/focus.css) owns the shared ring colour and standard width. [Input modality](../../../../packages/client/ui-primitives/README.md#input-modality) distinguishes tooltip input from keyboard focus navigation. -Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. +**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-brand-primary`. The global fallback sets only outline colour; it does not create an outline where the component disables one. -The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. +**Context-owned geometry.** `--dsw-focus-ring-width` carries the standard 2px width. Dense tables and toolbars may keep 1px so the ring does not dominate compact content. Components own their offsets and use an inset ring where an outer ring would be clipped. + +**Pointer focus does not imply keyboard navigation.** In pointer modality, `:focus-visible:not(:read-write)` controls use a transparent ring colour. Descendant and pseudo-element rings inherit it. Only focus-ring paint is suppressed: selected-state borders and elevation shadows remain, and editable text controls keep their own focus feedback on click. DOM focus stays on the control. + +Tooltips follow the last input and treat any key as keyboard input. Rings instead resume keyboard styling on non-composing navigation keys, or on focus reaching a different control after a non-composing key. Refocusing the same control is not navigation; pointer input, composition keys, and window blur clear the pending key. ## Alternatives considered -**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. +**Rewrite only the component declarations.** Explicit rings would agree, but controls using a browser-default outline would retain its colour. Requiring every control to declare its own ring would also leave new controls inconsistent until their authors add that rule. -**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. +**Unify width and offset globally.** Positive offsets can be clipped by a scrolling ancestor, while a uniform thick ring can overwhelm dense content. Neither colour consistency nor input modality requires identical geometry. -**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. +**Suppress by moving focus instead of by style.** Calling `blur()` on a pointer-focused button would hide its ring but also remove the target for subsequent Enter or Space activation. -**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. +**Clear all shadows on pointer focus.** A `box-shadow` can contain elevation or selected-state paint as well as a focus ring. Suppressing its ring colour preserves those independent cues; exempting `:read-write` preserves text-entry feedback. + +**Replace every `outline: none` with a shared ring.** Some controls use an inset shadow or container-level focus treatment instead; some containers deliberately show no ring. Replacing those declarations would change their focus treatment, not merely its colour. + +**Give tooltips and rings the same modality rule.** A key can justify a tooltip on subsequent programmatic focus without justifying a ring on the same pointer-focused button. Keeping separate answers preserves both behaviours. ## Consequences -The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. +Focus rings share a theme colour while components retain their geometry and non-focus state cues. Ring visibility changes without changing the keyboard's active target. These rules do not guarantee identical browser rendering or eliminate component-specific clipping. -`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. +[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts), and [input-modality tests](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts) cover stylesheet declarations and modality transitions. Static declarations and synthetic events do not establish rendered ring visibility or cross-platform behaviour. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md index 541537c54e..792374b92c 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -6,28 +6,38 @@ Status: implemented ## 问题 -客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 +不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。 + +焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。 ## 决策 -所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 +[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。 -实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 +**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-brand-primary`。全局兜底只设置轮廓颜色;组件禁用轮廓时,它不会创建轮廓。 -全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 +**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。 + +**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。 + +Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。 ## 考虑过的其他做法 -**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 +**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。 -**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 +**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。 -**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 +**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。 -**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 +**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。 + +**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。 + +**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。 ## 影响 -焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 +焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。 -`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 +[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。 diff --git a/apps/desktop/src/preload-menu.ts b/apps/desktop/src/preload-menu.ts index aa2f54a423..d913f1ed3e 100644 --- a/apps/desktop/src/preload-menu.ts +++ b/apps/desktop/src/preload-menu.ts @@ -22,7 +22,8 @@ export function installWindowsMenu(): { update(): void; dispose(): void } { font: inherit; font-size: 14px; cursor: default; } button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } - button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; } + button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } + :host-context(html[data-input-modality='pointer']) button:focus-visible { outline-color: transparent; } ` const bar = document.createElement('div') bar.setAttribute('role', 'menubar') diff --git a/apps/web/tests/focus-rings.e2e.ts b/apps/web/tests/focus-rings.e2e.ts new file mode 100644 index 0000000000..49a17c6b2e --- /dev/null +++ b/apps/web/tests/focus-rings.e2e.ts @@ -0,0 +1,357 @@ +/** Real assembled UI plus source-compiled browser regressions for focus-ring paint. */ +import { join } from 'node:path' +import { build } from 'vite' +import { chromium, type Locator, type Page } from 'playwright' +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, REPO_ROOT } from './support.ts' + +/** Read rendered paint and browser focus, not just the modality publisher's attribute. */ +async function paint(target: Locator) { + return target.evaluate((element) => { + const style = getComputedStyle(element) + const resolveColor = (value: string): string => { + const probe = document.createElement('span') + probe.style.color = value + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + } + return { + active: document.activeElement === element, + focusVisible: element.matches(':focus-visible'), + modality: document.documentElement.getAttribute('data-input-modality'), + brand: resolveColor(style.getPropertyValue('--dsw-alias-brand-primary').trim()), + hover: resolveColor(style.getPropertyValue('--dsw-alias-interactive-bg-hover').trim()), + outline: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + shadow: style.boxShadow, + background: style.backgroundColor, + } + }) +} + +async function expectSilent(target: Locator): Promise { + const state = await paint(target) + expect(state.active).toBe(true) + expect(state.modality).toBe('pointer') + expect(state.brand).toMatch(/^rgb/) + expect(state.outlineStyle === 'none' || state.outline === 'rgba(0, 0, 0, 0)').toBe(true) + expect(state.shadow).not.toContain(state.brand) +} + +async function expectBrand(target: Locator, kind: 'outline' | 'shadow'): Promise { + await expect.poll(async () => { + const state = await paint(target) + return { + active: state.active, + visible: state.focusVisible, + modality: state.modality, + painted: kind === 'shadow' + ? state.shadow.includes(state.brand) + : state.outline === state.brand && state.outlineStyle === 'solid' && Number.parseFloat(state.outlineWidth) > 0, + } + }).toEqual({ active: true, visible: true, modality: 'keyboard', painted: true }) +} + +it('assembled app: pointer keys stay silent; keyboard navigation and menu activation retain feedback', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch({ headless: true }) + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const trigger = page.getByRole('button', { name: /^Access mode, current:/ }) + + await trigger.click() + await page.getByRole('menu').waitFor() + await expectSilent(trigger) + await page.keyboard.press('Shift') + await expectSilent(trigger) + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await expectSilent(trigger) + + for (const key of ['Home', 'End']) { + await trigger.click() + await page.keyboard.press('Escape') + await expectSilent(trigger) + await page.keyboard.press(key) + await expectBrand(trigger, 'shadow') + } + + await trigger.click() + await page.keyboard.press('Escape') + await page.keyboard.press('Shift+Tab') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('Tab') + await expectBrand(trigger, 'shadow') + for (const key of ['Enter', 'Space']) { + await page.keyboard.press(key) + await page.getByRole('menu').waitFor() + await expectBrand(trigger, 'shadow') + const menu = page.getByRole('menu') + for (const [navigation, index] of [['End', -1], ['Home', 0]] as const) { + await page.keyboard.press(navigation) + const row = menu.getByRole('menuitem').nth(index) + const state = await paint(row) + expect(state.active).toBe(true) + expect(state.focusVisible).toBe(true) + expect(state.modality).toBe('keyboard') + // Menu rows intentionally use the hover fill instead of a second outline. + expect(state.background).toBe(state.hover) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + } + await page.keyboard.press('Escape') + await expectBrand(trigger, 'shadow') + } + expect(consoleWatch.warnings).toEqual([]) + expect(consoleWatch.pageErrors).toEqual([]) +}) + +/** + * Supplementary fixture, not an assembled app: Vite compiles the actual Menu, + * Switch, input-modality module and CSS Modules in memory. Only arrangement and + * state ownership belong to the fixture; no test writes the modality attribute. + */ +async function compileFixture(): Promise<{ script: string; css: string }> { + const source = (relative: string): string => JSON.stringify(join(REPO_ROOT, 'packages/client', relative).replaceAll('\\', '/')) + const entry = join(REPO_ROOT, 'apps/web/focus-rings-fixture.tsx').replaceAll('\\', '/') + const code = ` + import React, { useState } from 'react' + import { createRoot } from 'react-dom/client' + import { Menu } from ${source('ui-primitives/src/Menu.tsx')} + import { Switch } from ${source('ui-primitives/src/Switch.tsx')} + import ${source('ui-primitives/src/input-modality.ts')} + import ${source('ui-theme/src/styles/base.css')} + import ${source('ui-theme/src/styles/design-platform.css')} + import ${source('ui-theme/src/styles/focus.css')} + import ${source('ui-theme/src/styles/gradient-shadow-text.css')} + import triggerCss from ${source('ui-permission-presets/src/client/PermissionSelect.module.css')} + import feedbackCss from ${source('ui-message-feedback/src/client/FeedbackDialog.module.css')} + import workflowCss from ${source('ui-workflow-run/src/client/WorkflowRunPanel.module.css')} + import cardCss from ${source('ui-primitives/src/HoverCard.module.css')} + import pillCss from ${source('ui-primitives/src/Pill.module.css')} + import trajectoryCss from ${source('ui-trajectory/src/client/TrajectoryTable.module.css')} + function Fixture() { + const [checked, setChecked] = useState(false) + const [open, setOpen] = useState(false) + return
+ + + setOpen(false)} + anchor={} + items={[{ id: 'first', label: 'First item' }, { id: 'last', label: 'Last item' }]} /> +