Files
deepseek-harness/docs/subsystems/client-resources.md
T

8.0 KiB

Client Resources

English | 中文

The client resource model turns an address into live data for any Web Client component. dsh-client-resources 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); the decision record is the client resource model Agent Note.

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. 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). 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.

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). 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).

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.

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).

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).

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.