DSH / Atlas
2026-09-05implementedarchitecture

Workspace file service

工作区文件服务

The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a `read` tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as `workspace-file.ts`, beside session lifecycle it had nothing to do with. I

English

Problem

The Web client needs to look at files inside a session's workspace from a browser that may not be on the Host machine: a file the agent produced, the path a read tool row names, later a file tree and previews of files that are neither small nor text. The one endpoint that read a workspace file over the wire lived on the Session Controller as workspace-file.ts, beside session lifecycle it had nothing to do with. It returned a whole file under one total byte cap, so a large log could not be looked at even in part and a binary could not be looked at at all; it had no stat, no listing, and no change signal, so a preview could not learn that the agent had rewritten the file without re-reading it; and its result named the file by a Host url, a spelling nothing on the Client used as an address.

Two constraints frame the service. File reads through ctx.fs use the Session's composed filesystem backend, whose read authority may extend outside the workspace, while directory-tree and change-feed consumers are workspace-rooted. The service preserves the backend's read decisions for regular files while enforcing file-kind and bounded-buffer checks; list and changes retain workspace containment. And dsh-fs exposed one raw-byte read, readBytes(target, signal, maxBytes), which refuses any file longer than its cap: correct for an image the model ingests whole, useless for one window of a large file.

Decision

packages/api/workspace-files (@deepseek-ai/dsh-api-workspace-files) owns the Host ctx.workspaceFiles service, the workspaceFiles Remote namespace, and the Client file provider that turns stat and changes into live metadata for the resource model; dual-face packaging governs their package organization. File methods resolve relative paths from the workspace root but inherit the Session filesystem backend's read authority; list and changes remain workspace-scoped. The workspace file read authority owns this split and its security consequences. Results name files by their absolute path in the filesystem's execution world, and content is bounded by page, byte window, or complete-file cap. The byte window rides on a new dsh-fs seam, FileSystem.readByteRange, implemented by every provider. The Session Controller carries no workspace-file code.

Package topology

dual-face packaging supersedes this note's choice of separate Host and Client packages; the file service, paging, and change-feed decisions here remain in force. The workspace file read authority supersedes the original workspace-containment choice for file methods. Host and Client compile in separate leaf configurations, share wire types, and the Client does not import the Host runtime entry.

FacePackageFilesDepends on
Hostapi/workspace-files/tsconfig.host.jsonsrc/index.ts (WorkspaceFiles, Config, gates, pager), src/changes.ts (WorkspaceChangeFeed), src/types.ts (wire types, error codes)dsh-fs, dsh-sandbox-policy, dsh-typert-protocol, dsh-session, dsh-session-persistence
Clientapi/workspace-files/tsconfig.client.jsonsrc/client/index.ts (plugin body), provider.ts, change-feed.ts, remote.ts, types.ts, and shared src/types.tsdsh-api-gateway/client, dsh-session/types, dsh-client-resources, dsh-client-ui-slots, dsh-util-workspace-path, dsh-typert-protocol, and the package's generated ./remote

api/remotes and both root aggregates reference the matching Host/Client leaf. The package exports ., ./client, ./types, ./typert, and ./remote, with one workspace-files web-app row supplying both faces. The Client plugin injects ['resources', 'remote', 'remote.workspaceFiles']; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI.

The workspaceFiles file operations

Every Host method takes WorkspaceFileScope first. The workspaceFileScope Gateway lookup resolves Session identity. It reads the live Session header or, for a cold Session, SessionPersistence.stat; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its cwd, with the sandbox policy's deployment root used only when that header has no cwd. A Client passes its Session id and never names a root. The five signatures, as src/index.ts declares them:

@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
  • stat returns WorkspaceFileStat { absolutePath, version, bytes? }: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only.
  • read returns one window of lines, WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }; lines counts the page's lines, so a page holding one empty line (text: '', lines: 1) and a page past the end (lines: 0) read differently. range.offset is the 1-based first line and defaults to 1; range.limit is the largest number of lines and defaults to maxLines, which it may not exceed. Lines end at \n and a final \n terminates the last line rather than opening an empty one; text joins the page's lines with \n and carries no terminator; eof is true when the page includes the last line, and an offset past the end returns an empty page with eof true. The pager walks streamText, counts the lines before the window without keeping them, admits each in-window segment against maxBytes before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The version and bytes on a page are the stat's, taken before the stream.
  • readBytes returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under maxFileBytes when range is absent, or a bounded window under maxBytes when present. range: {} uses offset zero and the configured window cap; windows can read files above the complete-file cap. The binary transfer decision owns the Remote response encoding.
  • baseFile selects target resolution independently of range: the target path must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. Document Preview owns content loading and address semantics.
  • list returns WorkspaceDirectoryListing { path, entries, truncated }: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as { name, type, size? }, and whether maxEntries cut the list. type is file, directory, or other; a symlink child reports the type of what it points to and a dangling one is other. 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 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

Two path vocabularies leave the service, and each method uses exactly one. read, readBytes, stat, and changes name a file by absolutePath: its absolute path in the filesystem's execution world, symlinks resolved (ctx.fs.processPath(target)), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful stat.absolutePath, without reading a Session summary's cwd. list speaks workspace paths — the same syntax its path argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called absolutePath and not url because it is not a resource address; the address grammar belongs to dsh-util-workspace-path and is described with the resource model. Input paths to read, readBytes, stat, and list are absolute or relative to the session's workspace root, never to the backend's own cwd; readBytes with baseFile resolves the target from that file's directory.

version is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. offset means a line on read and a byte on readBytes; the two units never mix, and eof on either means the window reached the file's end.

File checks and workspace containment

read, readBytes, and stat share regular-file checks and then rely on the filesystem backend's read authority. list shares path inspection but also checks workspace containment, while changes filters observations to the workspace root. The service applies the following checks:

  1. The path itself. lstat inspects the path before anything follows it: a missing path is not-found, and a symlink — wherever it points, including back inside the workspace — is not-regular-file (kind symlink) for the file methods. 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; a final link resolves and passes containment before its target kind is read.

Failures

Each failure is one RemoteError code with typed details, declared beside the throwing code and discriminated by code, never by message.

CodeWhenDetails
workspace-file/not-foundno entry at the path, or the file vanished after the gates{ path }
workspace-file/outside-workspacea list target is not inside the workspace root{ path }
workspace-file/too-largea page's text or byte window exceeds maxBytes, or a complete read exceeds maxFileBytes{ path, limit }
workspace-file/not-textinvalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (read only){ path }
workspace-file/not-regular-fileread, readBytes, or stat on something that is not a regular file{ path, kind: 'directory' | 'symlink' | 'other' }
workspace-file/not-directorylist on something that is not a directory{ path, kind: 'file' | 'symlink' | 'other' }
workspace-file/unsupported-addressClient-minted: a resource address this provider cannot serve{ address }
workspace-file/unknown-workspaceClient-minted: an absolute address, which carries no Session{ address }
gateway/bad-requestan empty path, or an offset, limit, or length that is not an integer in range{}

The set is append-only: a code may be added, and none is renamed or removed, because consumers branch on these strings across the wire.

Configuration

Four fields, all validated positive integers changeable from cordis.yml, and no other tunables: maxBytes (default 2,097,152, 2 MiB) is the inclusive cap on one page's text and on one byte window; maxLines (default 5,000) is the default and largest page in lines; maxEntries (default 2,000) is the cap on returned directory entries; maxFileBytes (default 33,554,432, 32 MiB) caps complete and related-file reads. Paged and windowed reads impose no whole-file size cap.

The readByteRange seam in dsh-fs

A byte window of a large file needs a filesystem read bounded by the window, and FileSystem had only readBytes(target, signal, maxBytes), which bounds by the whole file. dsh-fs therefore gains a second raw-byte primitive:

abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array>

It returns the bytes at [offset, offset + length), shorter when the file ends inside the window and empty when offset lies at or past the end. The window is the bound: a backend transfers at most length bytes beyond the prefix it skips to reach offset and never buffers the whole file, so the caller's cap on length is the guard against unbounded buffering, sitting beside readBytes's bound rather than replacing it. The parameter order follows readText, streamText, and listDir — target, then the operation's own arguments, then an optional signal — rather than readBytes's signal-in-the-middle form, which is the one exception in the class. Both offset and length are non-negative integers by precondition; the seam is a typed same-process boundary and validates nothing, and the Remote method validates at the wire.

fs-local opens createReadStream(targetKey, { start: offset, end: offset + length - 1 }) after the same regular-file stat as its other reads, returning an empty array for length 0 without opening a stream; fs-sandbox extends LocalFileSystem and inherits it. The four test doubles that extend FileSystem implement the method too.

The Client file provider

The Client export registers one ResourceProvider<'file'> into ctx.resources for the plugin's lifetime and declares ResourceProtocolMap.file. The Document Preview package registers this package's exported WorkspaceFileParams as SidebarRightResourceParamsMap.file.

  • The value is metadata, WorkspaceFileStat { absolutePath, version, bytes? }; content never rides the stream because content can be arbitrarily large and a stream is for pushing change, not payload. A consumer reads pages with read (or windows with readBytes) and compares versions to know when they are stale; freshness and refresh belong to each consumer, not the shared observation.
  • The address names the file; its scope selects the Session. A session address's relative or absolute path reaches the Host unchanged; the workspace root supplies the base for a relative path, and the Session filesystem backend decides read access. Client cwd is not a prerequisite. An absolute address has no Session and fails with workspace-file/unknown-workspace; no current or tab Session is borrowed. Unsupported grammar yields workspace-file/unsupported-address. These two Client errors end the stream.
  • The frames. The first frame is a stat or its failure as an ok: false frame; the provider throws and catches nothing, because the Remote face never rejects and a throw inside a provider stream is a programming error left to surface. A Host write carrying a version the value does not hold yields that version with the byte count kept and no stat; a frame carrying the held version is dropped. A reported disappearance stats again — still there is fresh metadata, gone is a not-found frame with the previous value left for display. The follow is on the address, not the file: after a failed stat the stream continues, so the agent creating the file brings the resource live. Aborting the signal ends the stream silently.
  • One changes subscription per Session. The first follower opens remote.$stream, the last release disposes it, and successor streams and plugin teardown await pending closes. The Client starts its first stat only after accepting Host ready; sending a local WebSocket request is not Host acknowledgement. A follower registers by address, queues changes before its path is known, then filters queued and live frames by the successful stat's absolutePath, normalizing backslashes to slashes. Any Session write can trigger a re-stat before the first successful binding. Gateway supervision reconnects carrier loss; Host end or terminal failure ends followers and retains their last metadata until reopened.
  • Navigation parameters. SidebarRightResourceParamsMap.file is WorkspaceFileParams { line?: number }, a 1-based line to reveal. A line travels as a navigation parameter and not as part of the address, because the file is one piece of content whether it opens at the top or at line 400.

Related notes

The resource model owns ctx.resources, useResource, the dsh-resource://<type>/… address grammar, and the reasoning for one resource per address; the text preview and file tree are the shipped consumers of read, list, and the file provider; the right Sidebar docking infrastructure is the surface they open into; historical workspace file links is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through remote.workspaceFiles and the same file resource through useResource<'file'>; the wire types are published as @deepseek-ai/dsh-api-workspace-files/types. The workspace file read authority owns Host read access and the HTML security trade-off; Document Preview owns content loading and per-tab freshness.

Alternatives considered

Keeping the workspace file endpoint on the Session Controller. The first form: one read under a total byte cap, registered as a sub-plugin of the Session Controller because that is where the wire entry already was. Rejected because a Workspace File service is its own capability — reading and statting files plus listing and observing the workspace — and those queries belong together, while the Session Controller's concern is session lifecycle. The move also let the service grow to five methods without the Controller's file gaining a second purpose.

A dual-face package with reverse UI dependencies. The split-package choice followed two project-reference cycles after api/remotes referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with TS6202. dual-face packaging supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries.

Serving navigable workspace documents. Rejected on origin grounds; the binary transfer returns multipart data while HTML remains isolated in a Blob iframe.

Log-reachable or workspace-contained authorization for file reads. The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The workspace file read authority instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations.

Whole-file read and slice for the byte window. The interim form of readBytes read the file from its start to the window's end through readBytes(target, signal, offset + length) and sliced. It cannot read a window of a file longer than that end — the seam refuses such a file as too large — so no window could ever report eof: false, which contradicts the reason the method exists. Rejected in favour of the readByteRange seam, whose bound is the window.

Naming the file field url (or hostUrl). The Session Controller's WorkspaceFileText.url was the Host's file: URL of the file. Rejected once resource addresses existed: a URL on the wire reads as an address, and this one was not one — it was a differently encoded spelling of the same path the address carries, which the Client had to decode to match change frames. A wire field is named by what it is, so the field is absolutePath and the changes frames carry the same field.

A default readByteRange in the FileSystem base class. A non-abstract default over readBytes would have spared the test doubles a method but could only be implemented by reading the whole file up to the window's end, the very behaviour rejected above, or by passing an unbounded cap. Abstract, with every provider and double implementing it.

String-prefix containment for workspace operations. Comparing resolved path strings against the root is simpler than fs.contains, but resolve realpaths, so a symlink that leaves the root resolves to a path outside it while a prefix test on the unresolved spelling passes; and a prefix test on the resolved spelling still needs the backend's notion of "same file". The filesystem decides containment for list and changes.

Consequences

  • Workspace file access belongs to the Host/Client faces of api/workspace-files; the Session Controller carries neither implementation, and compiler and runtime entries stay separate. Header-only Session scope lets ordinary, subagent, live, and cold Sessions resolve their own relative paths without an Agent lifecycle or parent fallback.
  • A file of any size opens: text by line page, anything by byte window, each costing one page or window of memory on the Host; complete reads instead enforce maxFileBytes; the cost is that a consumer assembles pages itself and that a single line above maxBytes has no page at all, because pages are cut by lines.
  • Every filesystem provider offers windowed raw reads; fs-local seeks directly to the requested offset.
  • Paths on the wire are canonical: absolutePath and change frames spell a file with symlinks resolved. A follower binds to successful stat.absolutePath, so another spelling of the same file — a workspace root reached through a symlink — uses that canonical change key.
  • Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version.
  • File-kind inspection precedes backend reads, and list reports kind before an outside position. A page's version may be one write behind its content, and a stalled changes consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README.
  • The file resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action.
  • Office authorization probes use readBytes with a one-byte range; Document Preview uses the same method without range for complete-file formats.

Testing

Host specs in packages/api/workspace-files/tests exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through Uint8Array, version parity with stat, the cap as too-large, bad ranges, a window of a file far above the cap, and eof inferred without a size); stat; outside-workspace reads and backend refusals; list with containment, truncation, 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

  • A bound on a changes generation's queue.
  • Write and search operations; the service is read-only.
  • Scopes other than session in the file address; the grammar leaves room, the provider serves one.

中文

Problem

Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工作区里的文件:agent 产出的文件、read 工具行点名的路径,之后还有文件树,以及既不小也不是文本的文件预览。唯一一个经线路读取工作区文件的端点以 workspace-file.ts 住在 Session Controller 上,与它毫无关系的会话生命周期为邻。它在一个总字节上限之下返回整个文件,因此大日志连一部分都看不了、二进制根本看不了;它没有 stat、没有列举、没有变更信号,预览不重读就无法得知 agent 已改写文件;其结果还以 Host 的 url 命名文件,而 Client 上没有任何东西把这种拼法当地址用。

两个约束框定了这项服务。经 ctx.fs 的文件读取使用 Session 组合后的文件系统后端,其读取权限可能延伸到工作区外,而目录树与变更流消费方以工作区为根。服务为普通文件保留后端的读取决策,同时执行文件类型与有界缓冲检查;list 与 changes 保留工作区包含限制。另外 dsh-fs 只暴露一种原始字节读取 readBytes(target, signal, maxBytes),它拒绝任何比上限更长的文件:对模型整体摄入的图片是正确的,对大文件的一个窗口则毫无用处。

Decision

packages/api/workspace-files(@deepseek-ai/dsh-api-workspace-files)同时拥有 Host 服务 ctx.workspaceFiles、workspaceFiles Remote 命名空间,以及将 stat 与 changes 转成资源模型实时元数据的 Client file 提供者;包组织方式由双面包组织规定。文件方法从工作区根解析相对路径,但继承 Session 文件系统后端的读取权限;list 与 changes 仍限于工作区。工作区文件读取权限拥有这一分层及其安全后果。结果以文件在文件系统执行环境中的绝对路径命名文件,内容则受页、字节窗口或整文件上限约束。字节窗口依托 dsh-fs 新增的 seam FileSystem.readByteRange,由每个提供者实现。Session Controller 不再携带任何工作区文件代码。

包拓扑

双面包组织取代本记录中把 Host 与 Client 分成两个包的组织选择;这里的文件服务、分页和变更流约定保持不变。工作区文件读取权限取代文件方法原有的工作区包含选择。Host 与 Client 分别编译在两个叶配置中,共享线路类型,Client 不导入 Host 运行时入口。

面包文件依赖
Hostapi/workspace-files/tsconfig.host.jsonsrc/index.ts(WorkspaceFiles、Config、围栏、切页器)、src/changes.ts(WorkspaceChangeFeed)、src/types.ts(线路类型、错误码)dsh-fs、dsh-sandbox-policy、dsh-typert-protocol、dsh-session、dsh-session-persistence
Clientapi/workspace-files/tsconfig.client.jsonsrc/client/index.ts(插件体)、provider.ts、change-feed.ts、remote.ts、types.ts,以及共享的 src/types.tsdsh-api-gateway/client、dsh-session/types、dsh-client-resources、dsh-client-ui-slots、dsh-util-workspace-path、dsh-typert-protocol,以及本包生成的 ./remote

api/remotes 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 .、./client、./types、./typert 和 ./remote,web-app 中单个 workspace-files 条目供应两面。Client 插件注入 ['resources', 'remote', 'remote.workspaceFiles'];资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。

workspaceFiles 文件操作

每个 Host 方法首参都是 WorkspaceFileScope。workspaceFileScope Gateway lookup 解析 Session 身份。它优先读取 live Session header,cold Session 则只调用 SessionPersistence.stat;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 cwd,仅当该 header 没有 cwd 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。五个签名照 src/index.ts 的声明:

@Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise<WorkspaceFileText>
@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise<WorkspaceFileBytes>
@Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceFileStat>
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
@Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame>
  • stat 返回 WorkspaceFileStat { absolutePath, version, bytes? }:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。
  • read 返回一个行窗口 WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof };lines 计页内行数,使只含一个空行的页(text: ''、lines: 1)与越过文件末尾的页(lines: 0)可区分。range.offset 是 1 起算的首行,缺省 1;range.limit 是最多行数,缺省 maxLines 且不得超过。行以 \n 结束,末尾的 \n 终止最后一行而不是开启一空行;text 以 \n 连接本页各行且不带终止符;页含最后一行时 eof 为 true,越过末尾的 offset 返回 eof 为 true 的空页。切页器沿 streamText 前进,数过窗口前的行而不保留,把每个窗内片段先按 maxBytes 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 version 与 bytes 来自流之前的那次 stat。
  • readBytes 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 range 时,在 maxFileBytes 上限内完整读取;传入时,在 maxBytes 上限内读取有界窗口。range: {} 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。二进制传输决议负责 Remote 响应编码。
  • baseFile 独立于 range 选择目标解析方式:目标 path 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。Document Preview负责内容加载与地址语义。
  • list 返回 WorkspaceDirectoryListing { path, entries, truncated }:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 { name, type, size? } 给出,以及 maxEntries 是否截断了列表。type 为 file、directory 或 other;符号链接子项报告其指向目标的类型,悬空者为 other。列表报告为目录的子项经其解析目标打开,Windows 目录联接亦然,此时列表以该目标命名;目录链接列举拥有该规则。dotfile 照常列出,不做任何过滤。
  • changes 产出 WorkspaceFileWatchFrame:在观察队列注册且工作区根解析完成后先发 { kind: 'ready' },随后为 { kind: 'change', change }。载荷 WorkspaceFileChange 对存在的文件为 { absolutePath, version },对消失的文件为 { absolutePath, absent: true }。来源是工作区根内的 fs/observed,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。

线路上的路径

离开服务的路径词汇有两套,每个方法只用其中一套。read、readBytes、stat 与 changes 以 absolutePath 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(ctx.fs.processPath(target)),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 stat.absolutePath 绑定跟随者,不读取会话摘要的 cwd。list 说工作区路径——与其 path 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 absolutePath 而不叫 url,因为它不是资源地址;地址语法归 dsh-util-workspace-path 所有,与资源模型一并描述。read、readBytes、stat 与 list 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd;带 baseFile 的 readBytes 从基准文件所在目录解析目标。

version 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。offset 在 read 上指行、在 readBytes 上指字节;两套单位从不混用,二者的 eof 都表示窗口到达了文件末尾。

文件检查与工作区包含

read、readBytes 与 stat 共享普通文件检查,之后依赖文件系统后端的读取权限。list 共享路径检查,但还会检查工作区包含关系;changes 则把观察过滤到工作区根内。服务执行以下检查:

  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 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。

失败

每种失败都是一个带类型化 details 的 RemoteError 代码,声明在抛出它的代码旁,按代码而非消息区分。

代码何时Details
workspace-file/not-found路径处无条目,或文件在过关后消失{ path }
workspace-file/outside-workspacelist 的目标不在工作区根内{ path }
workspace-file/too-large一页文本或字节窗口超过 maxBytes,或全文读取超过 maxFileBytes{ path, limit }
workspace-file/not-text到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 read){ path }
workspace-file/not-regular-file对非普通文件执行 read、readBytes 或 stat{ path, kind: 'directory' | 'symlink' | 'other' }
workspace-file/not-directory对非目录执行 list{ path, kind: 'file' | 'symlink' | 'other' }
workspace-file/unsupported-addressClient 铸出:本提供者无法服务的资源地址{ address }
workspace-file/unknown-workspaceClient 铸出:不携带 Session 的 absolute 地址{ address }
gateway/bad-request空路径,或不是范围内整数的 offset、limit、length{}

这个集合只增不改不删:可以新增代码,但不重命名、不移除任何一个,因为消费方跨线路按这些字符串分支。

配置

四个字段,都是可在 cordis.yml 中修改、经校验的正整数,此外没有其他可调项:maxBytes(默认 2,097,152,即 2 MiB)是单页文本与单个字节窗口的含上限;maxLines(默认 5,000)是页的缺省与最大行数;maxEntries(默认 2,000)是返回目录条目数的上限;maxFileBytes(默认 33,554,432,即 32 MiB)限制全文及关联文件读取。分页和开窗读取不限制整个文件的大小。

dsh-fs 中的 readByteRange seam

大文件的字节窗口需要一种以窗口为界的文件系统读取,而 FileSystem 只有以整文件为界的 readBytes(target, signal, maxBytes)。因此 dsh-fs 新增第二个原始字节原语:

abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array>

它返回 [offset, offset + length) 处的字节,文件在窗内结束则变短,offset 位于或越过末尾则为空。窗口即界:后端最多传输为到达 offset 而跳过的前缀之外的 length 字节,从不缓冲整个文件,因此调用方对 length 的上限就是防无界缓冲的守卫,与 readBytes 的界并列而非取代它。参数顺序遵循 readText、streamText 与 listDir——先目标,再操作自己的参数,最后可选 signal——而不是 readBytes 把 signal 放中间的形式,那是该类中唯一的例外。offset 与 length 按前置条件都是非负整数;seam 是类型化的同进程边界,不做任何校验,由 Remote 方法在线路处校验。

fs-local 在与其他读取相同的普通文件 stat 之后打开 createReadStream(targetKey, { start: offset, end: offset + length - 1 }),对 length 为 0 直接返回空数组而不开流;fs-sandbox 继承 LocalFileSystem,随之继承该方法。继承 FileSystem 的四个测试替身也实现了该方法。

Client file 提供者

Client 导出向 ctx.resources 注册一个 ResourceProvider<'file'>,存活期与插件相同,并声明 ResourceProtocolMap.file。Document Preview 包把本包导出的 WorkspaceFileParams 注册为 SidebarRightResourceParamsMap.file。

  • 值是元数据,WorkspaceFileStat { absolutePath, version, bytes? };内容从不进入流,因为内容可以任意大,而流是用来推送变更而不是载荷的。消费者用 read 读页(或用 readBytes 开窗),并比较版本判断它们是否过时;新鲜度判断和刷新属于各消费方,不属于共享观察。
  • 地址命名文件,作用域决定读取会话。 session 地址携带的相对或绝对路径原样交给 Host;工作区根为相对路径提供基准,Session 文件系统后端决定读取权限。Client 不需要持有 cwd。absolute 地址不带 Session,以 workspace-file/unknown-workspace 失败,不借用当前或 tab 所属 Session。不支持的语法产生 workspace-file/unsupported-address。这两种 Client 错误会结束流。
  • 帧。 第一帧是 stat或其失败的 ok: false 帧;提供者不抛也不接,因为 Remote 面从不 reject,而提供者流里的抛错只可能是编程错误,任其浮出。携带值尚未持有的版本的 Host 写入产生该版本、保留字节数、不做 stat;携带已持有版本的帧被丢弃。报告的消失会再 stat 一次——仍在则是新元数据,不在则是保留上一个值供展示的 not-found 帧。跟随的是地址而不是文件:stat 失败后流继续,因此 agent 创建该文件会让资源恢复正常。中止 signal 则流静默结束。
  • 每会话一条 changes 订阅。 首位跟随者打开 remote.$stream,最后一位离开时释放,后继流和插件拆除等待关闭完成。Client 接受 Host 的 ready 后才开始首次 stat;本地发出 WebSocket 请求不是 Host 确认。跟随者先按地址注册,缓冲路径未知期间的变更,成功 stat 后按返回的 absolutePath 过滤排队与实时帧,反斜杠归一为斜杠。尚未成功绑定时,Session 内任何写入均可触发重新 stat。载体掉线由 Gateway 监督器重连;Host 结束或终态失败会结束跟随者,并保留最近元数据,直到重新打开。
  • 导航参数。 SidebarRightResourceParamsMap.file 是 WorkspaceFileParams { line?: number },即要显露的 1 起算行号。行号作为导航参数而不是地址的一部分传递,因为不论从顶部还是第 400 行打开,文件都是同一份内容。

相关记录

资源模型拥有 ctx.resources、useResource、dsh-resource://<type>/… 地址语法以及"每个地址一份资源"的推理;文本预览与文件树是 read、list 与 file 提供者随包交付的消费方;右侧 Sidebar 停靠基础设施是它们打开进去的界面;历史工作区文件链接是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 remote.workspaceFiles 触达同样的五个方法、经 useResource<'file'> 触达同样的 file 资源;线路类型以 @deepseek-ai/dsh-api-workspace-files/types 发布。工作区文件读取权限负责 Host 读取权限与 HTML 安全取舍;Document Preview负责内容加载和逐 tab 新鲜度。

Alternatives considered

把工作区文件端点留在 Session Controller 上。 最初形态:总字节上限之下的一个 read,作为 Session Controller 的子插件注册,因为线路入口本来就在那里。被否,因为 Workspace File 服务是自己的能力——读取和 stat 文件,并列举和观察工作区——这些查询应归于一处,而 Session Controller 关心的是会话生命周期。搬出也让服务长到五个方法而不给 Controller 的文件添第二重目的。

带有反向 UI 依赖的双面包。 拆包选择源于 api/remotes 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 TS6202 拒绝这些环。双面包组织取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。

提供可导航的工作区文档。 因源隔离问题不采用;二进制传输返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。

文件读取采用“日志可达”或工作区包含授权。 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。工作区文件读取权限改为以后端读取决策为准,只对工作区形态的操作保留包含限制。

为字节窗口整文件读取再切片。 readBytes 的临时形态经 readBytes(target, signal, offset + length) 从文件开头读到窗口末端再切片。它读不了比该末端更长的文件的窗口——seam 会以过大拒绝这样的文件——因此没有任何窗口能报告 eof: false,与该方法存在的理由相悖。被否,改为以窗口为界的 readByteRange seam。

把文件字段命名为 url(或 hostUrl)。 Session Controller 的 WorkspaceFileText.url 是 Host 侧文件的 file: URL。资源地址出现后即被否:线路上的 URL 读起来像地址,而这个不是——它只是地址所携同一路径的另一种编码拼法,Client 必须解码才能匹配变更帧。线路字段按其所是命名,因此字段为 absolutePath,changes 帧携带同一字段。

在 FileSystem 基类里给 readByteRange 一个默认实现。 基于 readBytes 的非抽象默认能免去测试替身一个方法,但只能靠把文件从头读到窗口末端来实现——正是上文否决的行为——或者传一个无界上限。改为抽象方法,由每个提供者与替身实现。

工作区操作使用字符串前缀包含判定。 把解析后的路径字符串与根比较比 fs.contains 简单,但 resolve 会取 realpath,离开根的符号链接解析到根外路径,而对未解析拼法的前缀测试会放行;对已解析拼法的前缀测试也仍需后端对"同一文件"的定义。list 与 changes 由文件系统判定包含关系。

Consequences

  • 工作区文件访问由 api/workspace-files 的 Host/Client 两面共同承担;Session Controller 不携带其中任何实现,两面的编译与运行时入口保持独立。header-only Session scope 让普通、subagent、live 与 cold Session 都能解析自己的相对路径,不需要 Agent 生命周期,也不回退父 Session。
  • 任意大小的文件都能打开:文本按行页、任何文件按字节窗口,在 Host 上各自只花一页或一窗内存;全文读取则受 maxFileBytes 约束;代价是消费者自己拼装页面,且单行超过 maxBytes 的行没有任何页,因为页按行切。
  • 每个文件系统提供者都提供开窗的原始读取;fs-local 直接定位到所请求的偏移量。
  • 线路上的路径是规范的:absolutePath 与变更帧以符号链接已解析的拼法命名文件。跟随者绑定到成功的 stat.absolutePath,因此同一文件的另一种拼法——经符号链接到达的工作区根——也使用该规范变更键。
  • 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。
  • 文件类型检查先于后端读取,list 也先报种类再报根外位置。页的 version 可能落后内容一次写入,停滞的 changes 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。
  • file 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。
  • Office 授权探测使用带单字节 range 的 readBytes;Document Preview 对整文件格式使用同一方法并省略 range。

Testing

packages/api/workspace-files/tests 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、Uint8Array 往返、版本、上限、坏范围以及无大小时的 eof);stat;工作区外读取及后端拒绝;list 的包含、截断、目录链接、目标已消失的链接与 not-directory;以及由 fs/observed 驱动并按根过滤的 changes。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。fs/fs 与 fs-local spec 钉住 readByteRange;dsh-util-workspace-path spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 stat、分页 read、list 与一帧可选启用的 changes。

Deferred

  • 给 changes 一代流的队列加上限。
  • 写入和搜索操作;本服务只读。
  • 文件地址中 session 之外的作用域;语法留有余地,提供者只服务一个。