Explorar o código

feat(resources): add Client resource registry and retained subscriptions

imccyu hai 3 semanas
pai
achega
3a85ac6d81

+ 88 - 0
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md

@@ -0,0 +1,88 @@
+# Agent Note: Client resource model
+
+Status: implemented
+
+English | [中文](2026-09-05-client-resource-model.zh.md)
+
+## Problem
+
+A right-Sidebar tab body, a chat card, or any other slot component often needs live data it knows only by address: the file an agent just wrote, later a chat node or a terminal. Before the resource model each consumer fetched for itself — the text preview owned its own Remote call and refresh loop — so every mount re-read, two components showing one file held two copies, switching tabs unmounted the body and lost its content, and each new kind of content meant a new bespoke hook.
+
+The tab record set the constraint. A tab must survive undo, redo, reload, and hot module replacement without the code that opened it, so the record can hold only serializable data: an address and navigation parameters. The opener therefore cannot hand a body its data, and injection is the wrong tool — injection is a registration-time relation between a domain and a seat, while opening is a runtime event. A component has to find its data from the address alone, through something registered once by whoever owns that kind of data.
+
+## Decision
+
+[`packages/client/resources`](../../../../packages/client/resources/README.md) (`@deepseek-ai/dsh-client-resources`) provides `ctx.resources` and the `useResource` global standard hook. Anything a consumer reads live is a **resource**, a resource is identified by its **address** and nothing else, and the address's protocol names the one **provider** that turns it into a frame stream.
+
+### Addresses
+
+A resource address is a `dsh-resource://<type>/…` URL. The host is the protocol key — the key of `ResourceProtocolMap` — and the path belongs to the protocol's owner. `RESOURCE_SCHEME = 'dsh-resource'` is the one scheme constant; `protocolOf(address)` parses the string with `new URL`, requires `protocol === 'dsh-resource:'`, and returns the lower-cased host, or `undefined` for a string the parser rejects, another scheme, or an empty host. `dsh-resource` is not one of the URL specification's special schemes, so the parser keeps the host's case and treats the path as opaque; the lower-casing is explicit, and each path segment is percent-encoded by the protocol that defines it. A protocol that needs a scope encodes it in the path: `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>`, with `session/<sessionId>` naming the session whose root resolves the file, or `dsh-resource://file/absolute/<absolute path>`, which carries no session and is read through the current one ([grammar](../../../../packages/util/workspace-path/README.md)). Any other scheme — `sidebar://guide` — is a navigation address: it names a tab, not data, and the model answers `none` for it ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)).
+
+### The service
+
+```ts ignore-check
+interface Resources {
+  register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void
+  pin(address: string, signal: AbortSignal): void
+  source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>>
+}
+
+interface ResourceProvider<P extends ResourceProtocol> {
+  readonly protocol: P
+  open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
+  reload?(address: string): void
+}
+
+interface ResourceSnapshot<Value> {
+  readonly status: 'none' | 'loading' | 'live' | 'failed'
+  readonly value: Value | undefined
+  readonly failure: RemoteFailure | undefined
+  readonly reload: () => void
+}
+
+type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
+```
+
+`register` owns exactly one provider per protocol: a second registration for the same protocol throws, and the registration is an effect on the registering plugin's fiber, so a protocol leaves with its plugin and may be registered again afterwards. `pin` holds a resource open without subscribing until the signal aborts; an already-aborted signal pins nothing. `source` is the bare observable behind the hook, reference-stable per address, for callers outside React. The value type is looked up in `ResourceProtocolMap`, declared as an empty interface in `ui-slots` beside `SlotMap` — a module augmentation cannot introduce an export the target module lacks, and every consumer already depends on `ui-slots` — and each protocol's owner declaration-merges its member (`file: WorkspaceFileResource`); the resources package re-exports the type.
+
+### The hook
+
+`useResource` is declared on `GlobalStandardProps` in `ui-slots`, so every slot component has it whatever its scope, and the plugin provides it through `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })`, the same root keyed-hook path `useSessions` uses. It is not a session standard prop: a resource carries its own scope in its address, and components outside any session scope read resources too. `useResource<P>(address)` returns the snapshot: `none` when the address's protocol has no provider or the address is not a resource address, `loading` between the stream opening and its first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or no `reload`.
+
+### Frames
+
+A provider yields `RemoteResult` frames: the current state first, one frame per later change. An `ok` frame makes the resource `live`, replaces the value, and clears the failure; an `ok: false` frame makes it `failed`, records the failure, and keeps the last value. Failure is data, not an exception: the Remote face already folds failures into `ok: false` and never rejects, providers pass those frames on, and the model neither catches nor wraps — a throw inside a provider's stream is a programming error left to surface. A stream that ends on its own keeps its last state; frames a provider yields after the release that aborted it are dropped and the iterator is returned. Streams carry metadata, not payload: the `file` value is `{ version, bytes?, changed }`, and a consumer reads content itself, by page, through the [Workspace Files service](2026-09-05-workspace-files-service.md).
+
+### Lifecycle
+
+One record exists per address. Its holders are the hook's subscribers plus pins; the first holder opens the provider's stream under an `AbortController`, later holders share it and read the latest value at once, and the last release aborts the stream and resets the snapshot to idle — `loading` while a provider is registered, `none` otherwise. A provider that arrives while an address is already held opens that address's stream; one that leaves aborts it and the address reads `none`. Records are kept for the page lifetime so `source(address)` stays reference-stable across React's render-then-subscribe window and a StrictMode remount, where a recreated record would resubscribe and restart the stream on every render.
+
+The right Sidebar's Tab domain pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream and switching back reads the latest value; a record restored by undo is a new pin, and a resource the model already let go is read again ([tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md)). `openResource(address)` accepts resource addresses only; pages such as the guide and the file tree are opened by kind and never enter the resource model.
+
+## Alternatives considered
+
+**Session-bound resources: `useResource` on the session kit and a `(session, address)` identity.** The first form. Rejected because a file is not a session concern — the session is only who authorizes the path — and because the model must serve protocols and components outside any session scope. Identity became the address alone, the scope moved into the address grammar, and the hook moved to the global kit.
+
+**Content in the resource stream.** Rejected: content can be arbitrarily large, and a stream is for pushing change, not payload. The stream carries metadata and the consumer reads content by page, which is also what lets one open tab hold a multi-megabyte file at the cost of one page.
+
+**Failure as a thrown error, wrapping a non-`RemoteFailure` throw as `gateway/internal`.** Rejected: the Remote face never rejects, so anything a provider throws is a bug, and wrapping it would be a fallback that hides the bug from the developer who caused it. A failure is an `ok: false` frame; a throw surfaces.
+
+**`file:/<scope>/<id>/<path>`, then `file://<scope>/<id>/<path>` with the scope in the authority.** Two earlier grammars. The single-slash form was not a URL the platform parser accepted, so every consumer hand-parsed it. Moving the scope into the authority made it a URL but gave each resource protocol its own scheme — `file://`, later `chat://`, `terminal://` — so the set of schemes grew with the set of protocols, a `file://` address no longer meant what it means everywhere else, and telling a resource address from a navigation address needed a list. The single `dsh-resource://<type>/…` scheme makes that test one comparison, leaves the host free to name the protocol, and keeps every other scheme available to navigation.
+
+**A hand-parsed scheme prefix instead of the URL parser.** The first `protocolOf` matched a regular expression for the scheme. Rejected once addresses were URLs: the parser already decides validity and case, and a string it rejects should read as "no protocol" rather than be half-parsed.
+
+**A per-tab stream hook, or a framework-managed `useTabResource(fetch)`.** Rejected in turn: a stream hook on the tab domain asks the wrong owner — `file` data must come from the workspace file service, chat data from the chat domain — and a framework-owned fetch has no good cache key. What remains is owner props on the tab plus one client-wide `useResource` keyed by address.
+
+## Consequences
+
+Any slot component reads live data by address and nothing else, so an opener passes data only and a body reconstructs itself from its record after undo, reload, or hot replacement. Two components showing one address share one stream, and a pinned address survives its body's unmount. A protocol's transport lives in exactly one provider, and adding a protocol is one declaration-merged type plus one registration.
+
+The costs are recorded here so they are not rediscovered. Records are never reclaimed: memory grows with the number of distinct addresses ever read, not with reads. Abort compliance rests with the provider; the model drops what a released stream still yields but cannot stop a provider that ignores the signal before its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call has to mint one. A navigation address or a malformed string reads as `none` rather than an error, which keeps mixed address lists cheap to render but gives a misspelled protocol no diagnostic beyond the missing value.
+
+## Testing
+
+`packages/client/resources/tests/resources.client.spec.ts` drives the registry with scripted feeds: protocol ownership and disposal, `none` for a protocol without a provider and for a navigation address, a provider arriving after a held address and leaving while it is held, registrations dropped with their fiber, open-on-first-holder and close-on-last, one source per address, pins including an already-aborted signal, a remount reading the latest value without reopening, reopening as a fresh stream, frames after abort dropped with the iterator returned, a stream ending on its own, failure frames beside the last value, and `reload` forwarding. `tests/apply.client.spec.ts` mounts the plugin in `SlotTestRuntime` and checks, through a root-scope probe component, that `useResource` reaches props, that rendering it opens the provider's stream, and that disposing the plugin withdraws both the service and the hook.
+
+## Deferred
+
+Reclaiming idle records, a resource-owned failure type decoupled from the Remote face, and the `chat` and `terminal` protocols are open; each waits for a consumer. The developer-facing reference is [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.md); the Sidebar that consumes the model is described in [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.md).

+ 88 - 0
.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md

@@ -0,0 +1,88 @@
+# Agent Note: 客户端资源模型
+
+Status: implemented
+
+[English](2026-09-05-client-resource-model.md) | 中文
+
+## Problem
+
+右侧 Sidebar 的 tab 正文、聊天卡片或任何别的 slot 组件,常常需要只以地址可知的活数据:agent 刚写的文件,将来的聊天节点或终端。资源模型出现前每个消费方各自取数——文本预览自己持有 Remote 调用与刷新循环——于是每次挂载都重读、两个组件显示同一文件就持有两份、切 tab 卸载正文就丢内容,每种新内容都意味着一个新的专用 hook。
+
+约束来自 tab 记录。tab 必须在打开它的代码不在场时挺过撤销、重做、刷新与热替换,所以记录只能存可序列化的数据:一个地址与导航参数。因此开启方不能把数据交给正文,注入也不是合适的工具——注入是领域与席位之间注册期的关系,而打开是运行期事件。组件必须只凭地址找到数据,途径是由数据拥有者注册一次的东西。
+
+## Decision
+
+[`packages/client/resources`](../../../../packages/client/resources/README.zh.md)(`@deepseek-ai/dsh-client-resources`)提供 `ctx.resources` 与 `useResource` 全局标准 hook。消费方活读的任何东西都是**资源**,资源只由其**地址**标识,地址的协议命名唯一一个把它变成帧流的**提供方**。
+
+### 地址
+
+资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 是协议键——`ResourceProtocolMap` 的键——路径归协议拥有者。`RESOURCE_SCHEME = 'dsh-resource'` 是唯一的 scheme 常量;`protocolOf(address)` 用 `new URL` 解析字串,要求 `protocol === 'dsh-resource:'`,返回小写 host;解析器拒绝的字串、其它 scheme 或空 host 返回 `undefined`。`dsh-resource` 不是 URL 规范里的特殊 scheme,解析器会保留 host 的大小写并把路径当作不透明串,所以小写化是显式做的,每段路径由定义它的协议做百分号编码。需要作用域的协议把作用域编进路径:`dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>`,`session/<sessionId>` 命名以其根解析该文件的会话;或 `dsh-resource://file/absolute/<绝对路径>`,不带会话、经当前会话读取([语法](../../../../packages/util/workspace-path/README.zh.md))。其它任何 scheme——`sidebar://guide`——是导航地址:它命名一个 tab 而非数据,模型对它回答 `none`([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。
+
+### 服务
+
+```ts ignore-check
+interface Resources {
+  register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void
+  pin(address: string, signal: AbortSignal): void
+  source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>>
+}
+
+interface ResourceProvider<P extends ResourceProtocol> {
+  readonly protocol: P
+  open(address: string, ctx: { readonly signal: AbortSignal }): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
+  reload?(address: string): void
+}
+
+interface ResourceSnapshot<Value> {
+  readonly status: 'none' | 'loading' | 'live' | 'failed'
+  readonly value: Value | undefined
+  readonly failure: RemoteFailure | undefined
+  readonly reload: () => void
+}
+
+type UseResource = <P extends ResourceProtocol>(address: string) => ResourceSnapshot<ResourceProtocolMap[P]>
+```
+
+`register` 让每个协议恰有一个提供方:同一协议的第二次注册抛错,注册是挂在注册方插件 fiber 上的 effect,所以协议随插件离开、之后可再注册。`pin` 在不订阅的情况下让资源保持打开直到信号中止;已中止的信号什么也不钉。`source` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用。值类型在 `ResourceProtocolMap` 里查得,它作为空接口声明在 `ui-slots` 里、与 `SlotMap` 并列——模块增强无法给目标模块添加它没有的导出,而每个消费方本来就依赖 `ui-slots`——各协议拥有者声明合并自己的成员(`file: WorkspaceFileResource`);resources 包再导出这个类型。
+
+### hook
+
+`useResource` 声明在 `ui-slots` 的 `GlobalStandardProps` 上,因此每个 slot 组件不论作用域都有它,插件经 `ctx.slots.provideRoot({ keyedHooks: { resource: address => resources.source(address) } })` 提供,与 `useSessions` 走同一条根 keyed hook 路径。它不是会话标准 prop:资源的作用域随地址携带,会话作用域之外的组件也要读资源。`useResource<P>(address)` 返回快照:地址协议没有提供方或地址不是资源地址时为 `none`,流已打开、首帧未到时为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作。
+
+### 帧
+
+提供方产出 `RemoteResult` 帧:首帧是当前状态,之后每次变化一帧。`ok` 帧使资源 `live`、替换值、清除失败;`ok: false` 帧使其 `failed`、记下失败、保留最后一个值。失败是数据不是异常:Remote 面本来就把失败折进 `ok: false` 且从不 reject,提供方原样转发这些帧,模型既不捕获也不包装——提供方流里抛出是编程错误,任其冒出。自行结束的流保持最后状态;提供方在中止它的那次释放之后产出的帧被丢弃,迭代器被归还。流只推元数据不推载荷:`file` 的值是 `{ version, bytes?, changed }`,消费方自己经 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)按页读内容。
+
+### 生命周期
+
+每个地址一条记录。持有者是 hook 的订阅者加 pin;第一个持有者在 `AbortController` 下打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并把快照重置为空闲——有提供方注册时为 `loading`,否则为 `none`。地址已被持有时到达的提供方会打开该地址的流;离开的提供方中止它,地址读作 `none`。记录在页面存续期内保留,使 `source(address)` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定,否则重建记录会让每次渲染重订阅、重开流。
+
+右侧 Sidebar 的 Tab 域在每条打开的 tab 记录存续期内钉住其地址,所以切 tab 卸载正文不关流、切回读到最新值;撤销恢复的记录是一次新的钉住,模型已放掉的资源会重新读取([tab 类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md))。`openResource(address)` 只收资源地址;引导页与文件树这类页面按 kind 打开,从不进入资源模型。
+
+## Alternatives considered
+
+**会话绑定的资源:`useResource` 挂会话标准件、身份为 `(session, address)`。** 第一版形态。被否,因为文件不是会话的事——会话只是路径的授权者——而且模型必须服务会话作用域之外的协议与组件。身份改为只有地址,作用域进入地址语法,hook 移到全局标准件。
+
+**内容进资源流。** 被否:内容可能任意大,流是用来推变化的,不是推载荷。流只带元数据,消费方按页读内容,这也是一个打开的 tab 能以一页的代价承载数兆字节文件的原因。
+
+**以抛错表达失败,并把非 `RemoteFailure` 的抛出包装成 `gateway/internal`。** 被否:Remote 面从不 reject,所以提供方抛出的任何东西都是 bug,包装它就是把 bug 藏起来不让肇事者看见的 fallback。失败是 `ok: false` 帧;抛出就冒出来。
+
+**`file:/<scope>/<id>/<path>`,再到把作用域放在 authority 位的 `file://<scope>/<id>/<path>`。** 两版更早的语法。单斜杠形态不是平台解析器接受的 URL,每个消费方都得手工解析。把作用域移到 authority 位使它成为 URL,却让每个资源协议各占一个 scheme——`file://`、将来的 `chat://`、`terminal://`——scheme 的集合随协议集合增长,`file://` 地址不再是它在别处的含义,区分资源地址与导航地址需要一张清单。单一 scheme `dsh-resource://<type>/…` 让这个判断只需一次比较,host 留给协议命名,其它所有 scheme 留给导航。
+
+**手写 scheme 前缀解析代替 URL 解析器。** 第一版 `protocolOf` 用正则匹配 scheme。地址成为 URL 后被否:解析器已经决定合法性与大小写,它拒绝的字串应读作「无协议」而不是被解析一半。
+
+**每 tab 一个流 hook,或框架代管的 `useTabResource(fetch)`。** 依次被否:挂在 tab 域上的流 hook 问错了拥有者——`file` 数据必须来自工作区文件服务,聊天数据来自聊天域——而框架代管的 fetch 没有好的缓存键。留下的是 tab 上的 owner props 加一个按地址的客户端级 `useResource`。
+
+## Consequences
+
+任何 slot 组件只凭地址读活数据,于是开启方只传数据,正文在撤销、刷新或热替换后能从记录重建自己。显示同一地址的两个组件共享一条流,被钉住的地址在正文卸载后仍存活。一个协议的传输只住在一个提供方里,新增协议只是一个声明合并的类型加一次注册。
+
+代价记录在此以免被重新发现。记录不回收:内存随读过的不同地址数增长,而非随读取次数增长。中止合规归提供方;模型会丢弃已释放的流仍产出的帧,却阻止不了忽略信号的提供方跑到下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。导航地址或畸形字串读作 `none` 而非报错,这让混合地址列表渲染起来便宜,却让拼错的协议除了缺值之外没有任何诊断。
+
+## Testing
+
+`packages/client/resources/tests/resources.client.spec.ts` 用脚本化的 feed 驱动注册表:协议归属与注销、无提供方的协议与导航地址都为 `none`、提供方在地址已被持有后到达与在持有中离开、注册随 fiber 消失、首个持有者开流末个关流、一址一源、包括已中止信号在内的 pin、重挂读到最新值且不重开、重开为新流、中止后帧丢弃且迭代器归还、流自行结束、失败帧与最后值并存、`reload` 转发。`tests/apply.client.spec.ts` 在 `SlotTestRuntime` 里挂载插件,经一个根作用域探针组件验证 `useResource` 到达 props、渲染它即打开提供方的流、dispose 插件同时撤走服务与 hook。
+
+## Deferred
+
+回收空闲记录、与 Remote 面解耦的资源自有失败类型、`chat` 与 `terminal` 协议都还开放;各自等待一个消费方。面向开发者的参考是 [docs/subsystems/client-resources.md](../../../../docs/subsystems/client-resources.zh.md);消费这个模型的 Sidebar 见 [docs/subsystems/sidebar-right.md](../../../../docs/subsystems/sidebar-right.zh.md)。

+ 94 - 0
docs/subsystems/client-resources.md

@@ -0,0 +1,94 @@
+# Client Resources
+
+English | [中文](client-resources.zh.md)
+
+The client resource model turns an address into live data for any Web Client component. [`dsh-client-resources`](../../packages/client/resources/README.md) provides the `ctx.resources` service and the `useResource` global standard hook; a package that owns a kind of content registers one **provider** for its **protocol**, and a component reads the content's current state by **address** without importing the owner's runtime. The right Sidebar's tabs are the model's first consumer ([Right Sidebar](sidebar-right.md)); the decision record is the [client resource model Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.md).
+
+This page is the developer reference: how to write an address, how to register a provider, how to read a resource, what the states and failures mean, and how the model holds and releases a resource.
+
+## Addresses
+
+A resource address is a `dsh-resource://<type>/…` URL. The host names the protocol and must be a key of `ResourceProtocolMap`; the path is the protocol's own, and its owner percent-encodes each segment. A protocol that needs a scope puts it in the path: the `file` protocol's addresses read `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>` or `dsh-resource://file/absolute/<absolute path without its leading />`, built with `fileAddressFor(sessionId, cwd, path)` and read back with `parseFileAddress(address)` from [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.md). The model itself reads only the scheme and the host: `protocolOf(address)` returns the lower-cased host of a `dsh-resource://` URL and `undefined` for anything else. Addresses under any other scheme — the Sidebar's `sidebar://guide` — name no resource and read as `none`.
+
+| Address | Protocol key | Reads as |
+|---|---|---|
+| `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered |
+| `dsh-resource://file/absolute/home/me/notes.md` | `file` | the metadata of that absolute path, read through the current session and confined to its workspace |
+| `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits |
+| `sidebar://guide` | — | `none`: a navigation address |
+| `/home/me/notes.md` | — | `none`: not a URL |
+
+## Registering a provider
+
+The owner of a protocol declares its value type on `ResourceProtocolMap` and registers one provider inside its own `ctx.effect`, so the protocol lives exactly as long as the plugin ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). `open(address, { signal })` returns a stream of `RemoteResult` frames — the current state first, then one frame per change — and must stop when `signal` aborts. A failure is an `ok: false` frame carrying a `RemoteFailure`; a throw inside the stream is a programming error and is not caught. `reload(address)` is optional and asks the open stream for a fresh frame.
+
+```ts ignore-check
+import type { Context } from '@deepseek-ai/cordis'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-client-resources/client'
+
+interface NoteView { readonly title: string; readonly updatedAt: string }
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap { note: NoteView }
+}
+
+export const inject = ['resources', 'remote']
+
+export function apply(ctx: Context): void {
+  ctx.effect(() => ctx.resources.register<'note'>({
+    protocol: 'note',
+    async *open(address, { signal }): AsyncIterable<RemoteResult<NoteView>> {
+      const id = new URL(address).pathname.slice(1)
+      yield await ctx.remote.notes.read(id, signal)
+      for await (const change of ctx.remote.notes.follow(id, signal)) yield change
+    },
+    reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) },
+  }), 'my-notes: note resource provider')
+}
+```
+
+A protocol has exactly one provider; a second registration throws. Registering while addresses of the protocol are already held opens their streams at once; disposing the provider ends those streams and the addresses read `none` until a provider returns.
+
+## Reading a resource
+
+Every slot component receives `useResource` in its props, whatever its scope ([Slots](slots.md)). `useResource<P>(address)` names the protocol as the type argument and returns the address's current snapshot; subscribing is what holds the resource open, and a component that mounts while another holder keeps the resource alive reads the latest value at once without reopening the stream ([read a resource](../../packages/client/resources/README.md#read-a-resource)).
+
+| `status` | Meaning | `value` | `failure` |
+|---|---|---|---|
+| `none` | No provider is registered for the address's protocol, or the address is not a resource address | `undefined` | `undefined` |
+| `loading` | The provider's stream is open and has not yielded yet | `undefined` | `undefined` |
+| `live` | The latest frame succeeded | the latest `ok` value | `undefined` |
+| `failed` | The latest frame reported a failure | the last `ok` value, kept | the frame's `RemoteFailure` |
+
+`reload()` asks the provider for a fresh frame and is a no-op when the protocol has no provider or the provider has no `reload`; the function is reference-stable per address, so a body may hold it.
+
+```tsx ignore-check
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
+
+type Props = PropsRuntime<'sidebar.right.pane.tab'>
+
+export function FileHeader({ tab, useResource, t }: Props) {
+  const meta = useResource<'file'>(tab.contentId)
+  if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
+  return (
+    <header>
+      {tab.title}
+      {meta.value?.changed && <button type="button" onClick={meta.reload}>{t('reload')}</button>}
+    </header>
+  )
+}
+```
+
+A consumer presents `failed` itself: the model keeps the last value beside the failure so a body can show stale content with a notice rather than a blank, and the next `ok` frame clears the failure. Nothing in the model produces user-visible text.
+
+## Holding and releasing
+
+A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
+
+Streams carry metadata, not content. The `file` provider's value is `{ version, bytes?, changed }`: `version` and `bytes` from the Host's `stat`, `changed` raised when the Host reports an agent write and cleared by `reload`. A consumer reads the file's text itself, by page, through the Workspace Files Remote namespace ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)).
+
+## Limits
+
+Records live for the page lifetime: an address's record stays after its last holder leaves, holding no stream and no value, so memory grows with the number of distinct addresses ever read. A provider that ignores `signal` keeps running until its next frame. The failure type is the Remote face's `RemoteFailure`, so a provider whose source is not a Remote call mints one. A misspelled protocol or a malformed address reads as `none` with no other diagnostic.

+ 94 - 0
docs/subsystems/client-resources.zh.md

@@ -0,0 +1,94 @@
+# 客户端资源
+
+[English](client-resources.md) | 中文
+
+客户端资源模型把一个地址变成任何 Web Client 组件都能读的活数据。[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 提供 `ctx.resources` 服务与 `useResource` 全局标准 hook;拥有某类内容的包为它的**协议**注册一个**提供方**,组件按**地址**读取该内容的当前状态,而无需引用拥有者的运行时。右侧 Sidebar 的 tab 是这个模型的第一个消费方([右侧 Sidebar](sidebar-right.zh.md));决策记录见 [客户端资源模型 Agent Note](../../.agents/notes/implemented/architecture/2026-09-05-client-resource-model.zh.md)。
+
+本页是面向开发者的参考:地址怎么写、提供方怎么注册、资源怎么读、状态与失败各是什么意思、模型怎样持有与释放一份资源。
+
+## 地址
+
+资源地址是 `dsh-resource://<type>/…` 形式的 URL。host 命名协议,必须是 `ResourceProtocolMap` 的键;路径归协议自己,由其拥有者逐段做百分号编码。需要作用域的协议把作用域放进路径:`file` 协议的地址形如 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>` 或 `dsh-resource://file/absolute/<去掉前导 / 的绝对路径>`,用 [`dsh-util-workspace-path`](../../packages/util/workspace-path/README.zh.md) 的 `fileAddressFor(sessionId, cwd, path)` 构造、`parseFileAddress(address)` 读回。模型本身只读 scheme 与 host:`protocolOf(address)` 对 `dsh-resource://` URL 返回小写 host,对其它任何字串返回 `undefined`。其它 scheme 下的地址——Sidebar 的 `sidebar://guide`——不指向资源,读作 `none`。
+
+| 地址 | 协议键 | 读作 |
+|---|---|---|
+| `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) |
+| `dsh-resource://file/absolute/home/me/notes.md` | `file` | 该绝对路径的元数据,经当前会话读取、受其工作区限制 |
+| `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 |
+| `sidebar://guide` | — | `none`:导航地址 |
+| `/home/me/notes.md` | — | `none`:不是 URL |
+
+## 注册提供方
+
+协议拥有者在 `ResourceProtocolMap` 上声明其值类型,并在自己的 `ctx.effect` 里注册一个提供方,使协议与插件同寿([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。`open(address, { signal })` 返回一条 `RemoteResult` 帧流——首帧是当前状态,之后每次变化一帧——并且必须在 `signal` 中止时停下。失败是携带 `RemoteFailure` 的 `ok: false` 帧;流里抛出是编程错误,不会被捕获。`reload(address)` 可选,请已打开的流给一个新帧。
+
+```ts ignore-check
+import type { Context } from '@deepseek-ai/cordis'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-client-resources/client'
+
+interface NoteView { readonly title: string; readonly updatedAt: string }
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap { note: NoteView }
+}
+
+export const inject = ['resources', 'remote']
+
+export function apply(ctx: Context): void {
+  ctx.effect(() => ctx.resources.register<'note'>({
+    protocol: 'note',
+    async *open(address, { signal }): AsyncIterable<RemoteResult<NoteView>> {
+      const id = new URL(address).pathname.slice(1)
+      yield await ctx.remote.notes.read(id, signal)
+      for await (const change of ctx.remote.notes.follow(id, signal)) yield change
+    },
+    reload(address) { ctx.remote.notes.requestReread(new URL(address).pathname.slice(1)) },
+  }), 'my-notes: note resource provider')
+}
+```
+
+一个协议恰有一个提供方;第二次注册抛错。注册时若该协议的地址已被持有,则立刻打开它们的流;提供方 dispose 时结束这些流,地址读作 `none` 直到提供方回来。
+
+## 读取资源
+
+每个 slot 组件不论作用域都在 props 上收到 `useResource`([Slots](slots.zh.md))。`useResource<P>(address)` 以类型参数命名协议,返回该地址的当前快照;订阅就是持有资源的方式,另一个持有者让资源存活时,新挂载的组件立刻读到最新值而不重开流([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
+
+| `status` | 含义 | `value` | `failure` |
+|---|---|---|---|
+| `none` | 地址的协议没有注册提供方,或地址不是资源地址 | `undefined` | `undefined` |
+| `loading` | 提供方的流已打开、尚未产出 | `undefined` | `undefined` |
+| `live` | 最新一帧成功 | 最新的 `ok` 值 | `undefined` |
+| `failed` | 最新一帧报告了失败 | 保留的上一个 `ok` 值 | 该帧的 `RemoteFailure` |
+
+`reload()` 请提供方给一个新帧,协议没有提供方或提供方没有 `reload` 时是空操作;该函数按地址引用稳定,正文可以长期持有。
+
+```tsx ignore-check
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-api-workspace-files/client'
+
+type Props = PropsRuntime<'sidebar.right.pane.tab'>
+
+export function FileHeader({ tab, useResource, t }: Props) {
+  const meta = useResource<'file'>(tab.contentId)
+  if (meta.status === 'failed') return <p role="alert">{t('failed', { code: meta.failure.code })}</p>
+  return (
+    <header>
+      {tab.title}
+      {meta.value?.changed && <button type="button" onClick={meta.reload}>{t('reload')}</button>}
+    </header>
+  )
+}
+```
+
+`failed` 由消费方自己呈现:模型把最后一个值留在失败旁,正文可以带提示显示旧内容而不是一片空白,下一个 `ok` 帧会清除失败。模型本身不产生任何用户可见文案。
+
+## 持有与释放
+
+资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
+
+流只推元数据不推内容。`file` 提供方的值是 `{ version, bytes?, changed }`:`version` 与 `bytes` 来自 Host 的 `stat`,`changed` 在 Host 报告 agent 写入时置起、由 `reload` 清除。消费方自己经 Workspace Files Remote 命名空间按页读文件文本([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。
+
+## 限制
+
+记录在页面存续期内保留:地址的记录在最后一个持有者离开后仍留着,不持有流也不持有值,因此内存随读过的不同地址数增长。忽略 `signal` 的提供方会一直跑到它的下一帧。失败类型是 Remote 面的 `RemoteFailure`,来源不是 Remote 调用的提供方得自己铸一个。拼错的协议或畸形的地址读作 `none`,没有别的诊断。

+ 108 - 0
packages/client/resources/README.md

@@ -0,0 +1,108 @@
+---
+description: "Client resource model: protocol-registered providers turn URL addresses into live values that any slot component reads through the useResource standard hook."
+kind: "package-reference"
+---
+# @deepseek-ai/dsh-client-resources
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The resource model of the web client. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose host is the protocol key; the protocol's owning client package registers a provider that turns an address into a value stream, and any slot component reads that stream through the `useResource` global standard hook. A protocol that needs a scope encodes it in the path (`dsh-resource://file/session/<sessionId>/<absolute path>`); the model knows only addresses, and an address under any other scheme (`sidebar://guide`) names no resource. Use it when a component needs live data it only knows by address (a tab record, a link, a mention) and the data's owner is another client plugin.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+  - [Read a resource](#read-a-resource)
+  - [Provide a protocol](#provide-a-protocol)
+  - [Hold a resource open](#hold-a-resource-open)
+- [Understand the implementation](#understand-the-implementation)
+  - [Lifecycle](#lifecycle)
+  - [Failures](#failures)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Nothing needs configuration to mount: the plugin provides `ctx.resources` and contributes the `resource` root keyed hook through `ctx.slots.provideRoot`, so every slot component receives it whatever its scope.
+
+<a id="read-a-resource"></a>
+### Read a resource
+
+Every slot component receives `useResource` in its props. `useResource<P>(address)` names the protocol as the type argument and returns `{ status, value, failure, reload }`: `none` when no provider is registered for the address's protocol (or the address is not a `dsh-resource://` URL), `loading` while the provider has not yielded, `live` with the latest `ok` frame's value, and `failed` when the latest frame reported a failure, with that failure beside the last value. `reload()` asks the provider for a fresh value and is a no-op without one. Subscribing through the hook is what holds the resource open; a component that mounts while another holder keeps the resource alive reads the latest value at once.
+
+<a id="provide-a-protocol"></a>
+### Provide a protocol
+
+The protocol's owning client package declares its value type in `ResourceProtocolMap` and registers one provider as an owned effect. `open` yields `RemoteResult` frames: the current content first and one frame per later change, with a failure as an `ok: false` frame rather than a throw; it must stop when `signal` aborts. `reload` is optional:
+
+```ts ignore-check
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap { note: NoteView }
+}
+
+export const inject = ['resources']
+
+export function apply(ctx) {
+  ctx.effect(() => ctx.resources.register<'note'>({
+    protocol: 'note',
+    async *open(address, { signal }) {
+      yield await readNote(address, signal)
+      for await (const change of followNote(address, signal)) yield change
+    },
+    reload(address) { requestReread(address) },
+  }), 'my-notes: note resource provider')
+}
+```
+
+A protocol has exactly one provider; a second registration throws. Registering a provider while addresses of its protocol are already held opens them; disposing it ends their streams and returns them to `none`.
+
+<a id="hold-a-resource-open"></a>
+### Hold a resource open
+
+`ctx.resources.pin(address, signal)` keeps a resource open without subscribing, until `signal` aborts. The right Sidebar pins every open tab's address for the tab record's lifetime, so switching tabs unmounts the body without closing its stream and switching back reads the latest value. `ctx.resources.source(address)` is the bare observable behind the hook, for callers outside React.
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<a id="lifecycle"></a>
+### Lifecycle
+
+One record per address holds a snapshot store, a holder count (hook subscribers plus pins), and the running stream's `AbortController`. The first holder opens the provider's stream; every later holder shares it; the last holder's release aborts the stream and resets the snapshot to idle (`loading` with a provider, `none` without). Records are kept for the page lifetime so `source()` stays reference-stable across React's render-then-subscribe window and a StrictMode remount. `reload` is one function per record and never changes.
+
+<a id="failures"></a>
+### Failures
+
+A failure is a frame, not a throw: a provider yields `{ ok: false, error }` and the resource turns `failed` with that error beside the last value; the next `ok` frame clears it. A stream that ends on its own keeps its last state. Frames that arrive after the release that aborted the stream are dropped, and the iterator is returned. A throw inside a provider's stream is a programming error and is not caught.
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package moves values between browser plugins and registers nothing model-facing.
+
+#### KV Cache effect
+
+None; resource streams do not assemble model requests.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **Records live for the page lifetime** — an address's record stays in the registry after its last holder leaves; only its state is discarded. Memory grows with the number of distinct addresses ever read, not with reads.
+- **Providers own abort compliance** — the registry drops what a released stream still yields, but a provider that ignores `signal` keeps working until its next frame.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. Provider ownership and holder counts have one owner, the registry, with no independent runtime source to compare against; registration disposal and the open/close lifecycle are asserted by behavior specs.

+ 108 - 0
packages/client/resources/README.zh.md

@@ -0,0 +1,108 @@
+---
+description: "客户端资源模型:按协议注册的提供方把 URL 地址变成活数据,任何 slot 组件都通过 useResource 标准 hook 读取。"
+kind: "package-reference"
+---
+# @deepseek-ai/dsh-client-resources
+
+[English](README.md) | 中文
+
+## 概述
+
+Web 客户端的资源模型。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,host 即协议键;协议所属的客户端包注册一个提供方把地址变成值的流,任何 slot 组件通过 `useResource` 全局标准 hook 读取这条流。需要作用域的协议把它编进路径(`dsh-resource://file/session/<sessionId>/<绝对路径>`);模型本身只认地址,其它 scheme 的地址(`sidebar://guide`)不指向资源。当组件需要的活数据只以地址形式可知(tab 记录、链接、提及),而数据的拥有者是另一个客户端插件时,请使用它。
+
+## 目录
+
+- [使用本包](#use-this-package)
+  - [读取资源](#read-a-resource)
+  - [提供协议](#provide-a-protocol)
+  - [钉住资源](#hold-a-resource-open)
+- [理解实现](#understand-the-implementation)
+  - [生命周期](#lifecycle)
+  - [失败](#failures)
+- [模型体验](#model-experience)
+- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+挂载无需任何配置:插件提供 `ctx.resources`,并通过 `ctx.slots.provideRoot` 贡献 `resource` 根 keyed hook,因此每个 slot 组件不论作用域都能收到它。
+
+<a id="read-a-resource"></a>
+### 读取资源
+
+每个 slot 组件都在 props 上收到 `useResource`。`useResource<P>(address)` 以类型参数命名协议,返回 `{ status, value, failure, reload }`:地址协议没有提供方(或地址不是 `dsh-resource://` URL)时为 `none`,提供方尚未产出值时为 `loading`,`live` 携带最新一个 `ok` 帧的值,`failed` 表示最新一帧报告了失败,失败放在最后一个值旁。`reload()` 请提供方给一个新值,没有提供方时是空操作。通过 hook 订阅就是钉住资源的方式;另一个持有者让资源保持存活时,新挂载的组件立刻读到最新值。
+
+<a id="provide-a-protocol"></a>
+### 提供协议
+
+协议所属的客户端包在 `ResourceProtocolMap` 声明其值类型,并以自有 effect 注册一个提供方。`open` 产出 `RemoteResult` 帧:先是当前内容,之后每次变化一帧,失败以 `ok: false` 帧而非抛错表达;必须在 `signal` 中止时停止。`reload` 可选:
+
+```ts ignore-check
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap { note: NoteView }
+}
+
+export const inject = ['resources']
+
+export function apply(ctx) {
+  ctx.effect(() => ctx.resources.register<'note'>({
+    protocol: 'note',
+    async *open(address, { signal }) {
+      yield await readNote(address, signal)
+      for await (const change of followNote(address, signal)) yield change
+    },
+    reload(address) { requestReread(address) },
+  }), 'my-notes: note resource provider')
+}
+```
+
+一个协议恰有一个提供方;第二次注册会抛错。提供方注册时若其协议的地址已被持有,则立即开流;提供方 dispose 时结束这些流并让它们回到 `none`。
+
+<a id="hold-a-resource-open"></a>
+### 钉住资源
+
+`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开,直到 `signal` 中止。右侧 Sidebar 在 tab 记录的存续期内钉住每个已打开 tab 的地址,因此切换 tab 卸载正文不会关闭其流,切回时读到最新值。`ctx.resources.source(address)` 是 hook 背后的裸 observable,供 React 之外的调用方使用。
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<a id="lifecycle"></a>
+### 生命周期
+
+每个地址一条记录,持有一个快照 store、一个持有者计数(hook 订阅者加 pin)与运行中流的 `AbortController`。第一个持有者打开提供方的流;之后的持有者共享它;最后一个持有者释放时中止流并把快照重置为空闲(有提供方为 `loading`,没有为 `none`)。记录在页面存续期内保留,使 `source()` 在 React 渲染到订阅的窗口与 StrictMode 重挂载之间保持引用稳定。`reload` 每条记录一个函数,永不变化。
+
+<a id="failures"></a>
+### 失败
+
+失败是帧而非抛错:提供方产出 `{ ok: false, error }`,资源变为 `failed` 并把该错误放在最后一个值旁;下一个 `ok` 帧将其清除。自行结束的流保持其最后状态。在中止流的那次释放之后到达的帧都被丢弃,并归还迭代器。提供方流内的抛错是编程错误,不会被捕获。
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,因为本包在浏览器插件之间搬运值,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;资源流不会组装模型请求。
+
+## 已知限制与暂缓事项
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **记录在页面存续期内保留**——地址的记录在最后一个持有者离开后仍留在注册表中,只丢弃其状态。内存随读取过的不同地址数增长,而非随读取次数增长。
+- **中止合规由提供方负责**——注册表会丢弃已释放的流仍产出的帧,但忽略 `signal` 的提供方会一直工作到它的下一帧。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变式:** 不发布伴生入口。提供方归属与持有者计数只有注册表这一个拥有者,没有可供比对的独立运行时来源;注册的 dispose 与打开/关闭生命周期由行为测试断言。

+ 57 - 0
packages/client/resources/package.json

@@ -0,0 +1,57 @@
+{
+  "name": "@deepseek-ai/dsh-client-resources",
+  "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/resources"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-client-ui-renderer"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 122 - 0
packages/client/resources/src/client/contract.ts

@@ -0,0 +1,122 @@
+/**
+ * The resource model's published face.
+ *
+ * A resource is one address, and a resource address is a
+ * `dsh-resource://<type>/…` URL: the host names the protocol. The protocol's
+ * owning client package registers one {@link ResourceProvider} that turns an
+ * address into a frame stream, and any slot component reads that stream through
+ * {@link UseResource}. A protocol that needs a scope (a session, a workspace)
+ * encodes it in the path, as `dsh-resource://file/session/<sessionId>/<absolute
+ * path>` does; the model itself knows only addresses. Addresses under any other
+ * scheme (`sidebar://guide`) are navigation addresses and name no resource.
+ * `ResourceProtocolMap` (declared
+ * in ui-slots) is the declaration-merged roster of protocol to value type, so a
+ * consumer names the protocol as a type argument and receives the owner's value
+ * type without importing the owner's runtime.
+ */
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { ResourceProtocolMap } from '@deepseek-ai/dsh-client-ui-slots'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface GlobalStandardProps {
+    /** Live value of one address, resolved through the provider registered for its protocol. */
+    useResource: UseResource
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Resource model: protocol providers, pins, and per-address live sources. */
+    resources: Resources
+  }
+}
+
+/** Every protocol some client package has declared. */
+export type ResourceProtocol = Extract<keyof ResourceProtocolMap, string>
+
+/**
+ * Where one resource stands. `none`: no provider is registered for the
+ * address's protocol, or the address is not a resource address. `loading`: a provider is open and has not yielded yet.
+ * `live`: `value` is the latest `ok` frame's value. `failed`: the latest frame
+ * reported a failure.
+ */
+export type ResourceStatus = 'none' | 'loading' | 'live' | 'failed'
+
+/** One address's current state, as `useResource` returns it. */
+export interface ResourceSnapshot<Value> {
+  readonly status: ResourceStatus
+  /** The latest `ok` frame's value; kept through a later failure frame, absent before the first. */
+  readonly value: Value | undefined
+  /** The latest frame's failure; present only while `status` is `failed`. */
+  readonly failure: RemoteFailure | undefined
+  /** Ask the provider for a fresh frame; a no-op when its protocol has no provider or no `reload`. */
+  readonly reload: () => void
+}
+
+/**
+ * Global standard hook: the current state of one address, typed by the
+ * protocol named as the type argument. Present on every slot component's
+ * props, whatever its scope.
+ */
+export type UseResource = <P extends ResourceProtocol>(
+  address: string,
+) => ResourceSnapshot<ResourceProtocolMap[P]>
+
+/** What a provider's `open` receives beside the address. */
+export interface ResourceOpenContext {
+  /** Aborted when the last subscriber or pin releases the resource; the stream must end. */
+  readonly signal: AbortSignal
+}
+
+/** One protocol's provider, registered through `ctx.resources.register`. */
+export interface ResourceProvider<P extends ResourceProtocol> {
+  /** The URL scheme this provider serves. */
+  readonly protocol: P
+  /**
+   * Open one frame stream for an address. The first frame is the current
+   * content and every later frame one change. An `ok` frame replaces the value;
+   * a failure frame marks the resource `failed` with its error and keeps the
+   * last value. Ending the stream keeps the last state. A failure is always a
+   * frame: a throw inside the stream is a programming error and is not caught.
+   * @param address - the full address, a `dsh-resource://<type>/…` URL.
+   * @param ctx - the stream's abort signal.
+   * @returns the frame stream; it must stop once `ctx.signal` aborts.
+   */
+  open(address: string, ctx: ResourceOpenContext): AsyncIterable<RemoteResult<ResourceProtocolMap[P]>>
+  /**
+   * Produce a fresh frame on the open stream. Absent when the protocol has no refresh.
+   * @param address - the full address, a `dsh-resource://<type>/…` URL.
+   */
+  reload?(address: string): void
+}
+
+/**
+ * The `ctx.resources` service. One resource is one address; it stays open
+ * while at least one `source` subscriber or one pin holds it, and the
+ * provider's stream is aborted and the state discarded when the last holder
+ * releases.
+ */
+export interface Resources {
+  /**
+   * Register the provider for one protocol for the caller's lifetime.
+   * @param provider - the protocol's provider.
+   * @returns idempotent disposer, held inside the caller's own `ctx.effect`.
+   * @throws when the protocol already has a provider.
+   */
+  register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void
+  /**
+   * Hold one resource open without subscribing to it.
+   * @param address - the full address, a `dsh-resource://<type>/…` URL.
+   * @param signal - aborting it releases the pin; an already-aborted signal pins nothing.
+   */
+  pin(address: string, signal: AbortSignal): void
+  /**
+   * The live source of one resource. Reference-stable for one address while
+   * the resource is held; the first subscriber or pin opens the provider's
+   * stream, and a subscriber arriving later reads the latest value at once.
+   * @param address - the full address, a `dsh-resource://<type>/…` URL.
+   * @returns the observable state; `getSnapshot` reads without holding the resource.
+   */
+  source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>>
+}

+ 41 - 0
packages/client/resources/src/client/index.ts

@@ -0,0 +1,41 @@
+/**
+ * Browser half: `ctx.resources` (protocol-registered providers, pinning, live
+ * sources) and the `useResource` global standard hook.
+ */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+// Type-only service merge for ctx.slots.
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots'
+import { ResourceRegistry } from './resources.ts'
+
+export type {
+  ResourceOpenContext,
+  ResourceProtocol,
+  ResourceProvider,
+  Resources,
+  ResourceSnapshot,
+  ResourceStatus,
+  UseResource,
+} from './contract.ts'
+export type { ResourceProtocolMap } from '@deepseek-ai/dsh-client-ui-slots'
+
+/** Required browser services. */
+export const inject = ['slots']
+
+/**
+ * Client plugin body: provide `ctx.resources` and contribute the `resource`
+ * root keyed hook that reaches every slot component as `useResource`.
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  // Built at apply's top level, never inside an effect: other plugins call
+  // `register()` from their own apply, and it adds an effect to this fiber.
+  const resources = new ResourceRegistry(ctx)
+  const disposeService = ctx.reflect.provide('resources', resources)
+  // Registered first, so it tears down last: the face outlives every provider
+  // that registered into it.
+  ctx.effect(() => () => { void disposeService() }, 'client-resources: service face')
+  ctx.slots.provideRoot({
+    keyedHooks: { resource: address => resources.source(address) },
+  } satisfies RootStandardSourceContribution)
+}

+ 217 - 0
packages/client/resources/src/client/resources.ts

@@ -0,0 +1,217 @@
+/**
+ * `ctx.resources`: the provider registry and the per-address states behind
+ * `useResource`.
+ *
+ * A record is kept for every address ever sourced and is never dropped; what
+ * the last release discards is its state (the stream is aborted and the
+ * snapshot returns to idle). Keeping the record keeps `source()` reference-stable
+ * across React's render-then-subscribe window and a StrictMode remount, where a
+ * recreated record would make every render resubscribe and restart the stream.
+ */
+import type { Context } from '@deepseek-ai/cordis'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type {
+  ResourceOpenContext,
+  ResourceProtocol,
+  ResourceProvider,
+  Resources,
+  ResourceSnapshot,
+} from './contract.ts'
+
+/** A provider with its value type erased, so one map holds every protocol. */
+interface RuntimeProvider {
+  readonly protocol: string
+  open(address: string, ctx: ResourceOpenContext): AsyncIterable<RemoteResult<unknown>>
+  reload?(address: string): void
+}
+
+/** One address: its state, its holders, and the running stream. */
+interface ResourceRecord {
+  readonly address: string
+  /** The address's protocol key (`dsh-resource://` host); absent when the address is not a resource address. */
+  readonly protocol: string | undefined
+  readonly store: SnapshotStore<ResourceSnapshot<unknown>>
+  readonly source: ObservableSnapshot<ResourceSnapshot<unknown>>
+  readonly reload: () => void
+  /** Subscribers plus pins; the stream runs while this is positive. */
+  holders: number
+  /** Present while the provider's stream runs; aborting it ends the stream. */
+  controller: AbortController | undefined
+}
+
+/**
+ * The one URL scheme resource addresses use: `dsh-resource://<type>/…`, where
+ * the host names the protocol. Other schemes (`sidebar://…`) are navigation
+ * addresses and name no resource.
+ */
+export const RESOURCE_SCHEME = 'dsh-resource'
+
+/**
+ * The protocol key of one address: the host of a `dsh-resource://` URL, as the
+ * URL parser reads it (lower-cased). Any other string — another scheme, or one
+ * the URL parser rejects — names no protocol and is treated like an address
+ * whose protocol has no provider.
+ * @param address - the full address.
+ * @returns the protocol key, or `undefined` when the address is not a resource address.
+ */
+export function protocolOf(address: string): string | undefined {
+  let parsed: URL
+  try {
+    parsed = new URL(address)
+  } catch {
+    // The URL parser rejects strings without a scheme (`/a/b.txt`, `''`);
+    // nothing else throws here, and an unparseable address is simply not ours.
+    return undefined
+  }
+  if (parsed.protocol !== `${RESOURCE_SCHEME}:`) return undefined
+  // A non-special scheme's host is opaque to the URL parser and keeps its case.
+  return parsed.hostname === '' ? undefined : parsed.hostname.toLowerCase()
+}
+
+function idle(status: 'none' | 'loading', reload: () => void): ResourceSnapshot<unknown> {
+  return { status, value: undefined, failure: undefined, reload }
+}
+
+/** The `ctx.resources` implementation. */
+export class ResourceRegistry implements Resources {
+  private readonly providers = new Map<string, RuntimeProvider>()
+  private readonly records = new Map<string, ResourceRecord>()
+
+  /** @param ctx - Context whose effects own the registered providers. */
+  constructor(private readonly ctx: Context) {}
+
+  register<P extends ResourceProtocol>(provider: ResourceProvider<P>): () => void {
+    const runtime: RuntimeProvider = provider
+    const { protocol } = runtime
+    if (this.providers.has(protocol)) {
+      throw new Error(`resources: protocol "${protocol}" already has a provider`)
+    }
+    const dispose = this.ctx.effect(() => {
+      this.providers.set(protocol, runtime)
+      for (const record of this.recordsOf(protocol)) this.attach(record)
+      return () => {
+        this.providers.delete(protocol)
+        for (const record of this.recordsOf(protocol)) this.detach(record)
+      }
+    }, `resources.register(${JSON.stringify(protocol)})`)
+    return () => { void dispose() }
+  }
+
+  pin(address: string, signal: AbortSignal): void {
+    if (signal.aborted) return
+    const record = this.record(address)
+    this.hold(record)
+    signal.addEventListener('abort', () => { this.release(record) }, { once: true })
+  }
+
+  source(address: string): ObservableSnapshot<ResourceSnapshot<unknown>> {
+    return this.record(address).source
+  }
+
+  private record(address: string): ResourceRecord {
+    let record = this.records.get(address)
+    if (record === undefined) {
+      record = this.create(address)
+      this.records.set(address, record)
+    }
+    return record
+  }
+
+  private create(address: string): ResourceRecord {
+    const protocol = protocolOf(address)
+    const reload = (): void => {
+      this.providerOf(protocol)?.reload?.(address)
+    }
+    const store = createSnapshotStore<ResourceSnapshot<unknown>>(
+      idle(this.providerOf(protocol) === undefined ? 'none' : 'loading', reload),
+    )
+    const record: ResourceRecord = {
+      address,
+      protocol,
+      store,
+      reload,
+      holders: 0,
+      controller: undefined,
+      source: {
+        getSnapshot: () => store.getSnapshot(),
+        subscribe: (listener) => {
+          const unsubscribe = store.subscribe(listener)
+          this.hold(record)
+          let active = true
+          return () => {
+            if (!active) return
+            active = false
+            unsubscribe()
+            this.release(record)
+          }
+        },
+      },
+    }
+    return record
+  }
+
+  private providerOf(protocol: string | undefined): RuntimeProvider | undefined {
+    return protocol === undefined ? undefined : this.providers.get(protocol)
+  }
+
+  private *recordsOf(protocol: string): Iterable<ResourceRecord> {
+    for (const record of this.records.values()) {
+      if (record.protocol === protocol) yield record
+    }
+  }
+
+  private hold(record: ResourceRecord): void {
+    record.holders += 1
+    if (record.holders === 1) this.start(record)
+  }
+
+  private release(record: ResourceRecord): void {
+    record.holders -= 1
+    if (record.holders > 0) return
+    this.stop(record)
+    record.store.set(idle(this.providerOf(record.protocol) === undefined ? 'none' : 'loading', record.reload))
+  }
+
+  /** The provider arrived: a held record opens its stream, an idle one turns `loading`. */
+  private attach(record: ResourceRecord): void {
+    if (record.holders > 0) {
+      this.start(record)
+      return
+    }
+    record.store.set(idle('loading', record.reload))
+  }
+
+  /** The provider left: the stream ends and the record reports `none`. */
+  private detach(record: ResourceRecord): void {
+    this.stop(record)
+    record.store.set(idle('none', record.reload))
+  }
+
+  private start(record: ResourceRecord): void {
+    const provider = this.providerOf(record.protocol)
+    if (provider === undefined) return
+    const controller = new AbortController()
+    record.controller = controller
+    if (record.store.getSnapshot().status !== 'loading') record.store.set(idle('loading', record.reload))
+    void this.consume(record, provider, controller.signal)
+  }
+
+  private stop(record: ResourceRecord): void {
+    record.controller?.abort()
+    record.controller = undefined
+  }
+
+  /** Failures arrive as frames; a throw inside the stream is left to surface. */
+  private async consume(record: ResourceRecord, provider: RuntimeProvider, signal: AbortSignal): Promise<void> {
+    const stream = provider.open(record.address, { signal })
+    for await (const frame of stream) {
+      // A frame the provider yields after the release that aborted it belongs
+      // to nobody; ending the loop also returns the iterator.
+      if (signal.aborted) break
+      record.store.set(frame.ok
+        ? { status: 'live', value: frame.value, failure: undefined, reload: record.reload }
+        : { status: 'failed', value: record.store.getSnapshot().value, failure: frame.error, reload: record.reload })
+    }
+  }
+}

+ 4 - 0
packages/client/resources/src/index.ts

@@ -0,0 +1,4 @@
+/** Pure host half; the resource model lives in the browser export. */
+
+/** Host plugin body: the resource model contributes nothing to the host tree. */
+export function apply(): void {}

+ 27 - 0
packages/client/resources/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-slots"
+    }
+  ]
+}

+ 3 - 0
packages/client/resources/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-resources', ['lib/types/index.js'])

+ 8 - 0
packages/client/ui-slots/src/index.ts

@@ -35,6 +35,14 @@ export interface SlotMap {}
  */
 export interface LocaleNamespaceMap {}
 
+/**
+ * Resource protocol (URL scheme) → the value its provider streams. Declared
+ * empty here, the zero-dependency merge point; each protocol owner merges its
+ * own member (`file`, later `chat`), and `useResource<P>(address)` narrows its
+ * value by `P`. The resource service itself lives in `dsh-client-resources`.
+ */
+export interface ResourceProtocolMap {}
+
 /**
  * Translate a dictionary key with optional `{name}` template params.
  * `K` narrows the accepted keys to the owning namespace's dictionary union