mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
95 lines
8.0 KiB
Markdown
95 lines
8.0 KiB
Markdown
# 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 `{ absolutePath, version, bytes?, changed }`: `absolutePath`, `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.
|