joao-paulo-santos/dsh-granular-settings0

dsh-granular-settings

Granular settings platform: one Granular Settings page in the settings dialog with Workspace/Session/Plugin tabs. Three scopes (workspace, session, global) registered by plugins, namespaced storage, doorbell-only push.

包名
dsh-granular-settings
版本
0.14.0
许可证
MIT
最近更新
2026年8月26日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:joao-paulo-santos/dsh-granular-settings

dsh-granular-settings

A DeepSeek Harness (DSH) plugin: install it into a profile alongside your own plugins.

Settings that other plugins contribute. Plugins need knobs at more than one granularity: a model choice for this session, a build flag for the whole workspace, an accent color for the entire install. This plugin is the shared platform for all of them. One registration API, nine control types, three scopes. One Granular Settings page renders everything, grouped one box per plugin, so users never hunt through a settings maze built by N different authors.

Every registration is namespaced by the owning plugin's package name, so enabled from dsh-foo and enabled from dsh-bar are different settings in different namespaces. Generic keys are safe; collisions cannot happen.

Architecture

flowchart LR
    subgraph HOST["Host, dsh-granular-settings host half"]
        R["gs.register(...)
namespace, scope, key, type"]
        V["validate, apply defaults
min/max, options"]
        S[("workspace.json
sessions/<sid>.json
plugins/<ns>.json")]
        A["GET /granular-settings/describe
POST /granular-settings/set"]
        P["publish doorbell
granular-settings/<ns>/change"]
    end
    subgraph RELAY["dsh-event-relay, SSE"]
        M["data: {topic, payload: null}"]
    end
    subgraph BROWSER["Browser tab, dsh-granular-settings client half"]
        D["describe cache
one stream, refcounted"]
        G["Granular Settings page
Workspace / Session / Plugin tabs"]
        C["granularSettingsClient
valueOf, set, onChange"]
    end
    R --> V --> S
    A --> S
    A --> P
    P --> M --> D
    D --> G
    D --> C

Three scopes, one page

scopevalue livesstorage
sessionone value per session/.dsh/settings/sessions/.json
workspaceone value per workspace, shared by its sessions/.dsh/settings/workspace.json
globalplugin-wide, unrelated to any workspace or session~/.dsh/settings/plugins/.json, one flat file per plugin, served from a boot-hydrated in-memory cache

The settings-dialog page has three tabs, Workspace, Session, Plugin, one per scope, rendered by the same components. It works with no session open: the Plugin tab is the only scope that exists without a session context. A Granular Settings view tab can also sit beside Chat in each session; it renders the same page, and its visibility is itself a plugin setting, default on.

Plugin value proposition

alternativefalls short
every plugin ships its own settings UIN surfaces in N styles; users hunt; every author re-solves rendering, validation, persistence, and sync
a config file or environment variablesno UI, no live apply; edits made while the harness runs are overwritten or missed
one global key-value storeno scoping; a per-session toggle or a per-workspace path cannot exist
localStoragevalues live in one browser profile, never reach the host or another machine
host RPC alonecorrect but raw: every plugin rebuilds caching, default fallback, and change push

How to install

Requires a DeepSeek Harness checkout, a profile, here web, and dsh-event-relay, whose stream carries the live-update doorbells:

# from the harness checkout
pnpm dsh plugin --profile web add /path/to/dsh-event-relay
pnpm dsh plugin --profile web add /path/to/dsh-granular-settings

# verify the profile still composes
pnpm dsh --profile web --dump-config

Then restart the harness. The Granular Settings page appears in the settings dialog; nothing else to wire up.

Contributors (host plugins)

const gs = ctx.get('granularSettings')            // hard dep: inject it
const s = gs.register({
  namespace: 'my-plugin',       // REQUIRED: package name, [a-z][a-z0-9-]*, stable identity
  scope: 'session',             // 'session' | 'workspace' | 'global'
  key: 'model',                 // [a-z][a-z0-9-]*, unique only within the namespace
  type: 'enum',                 // see control types below
  label: 'Model',
  owner: 'My Plugin',           // display name for the settings box
  description: 'Which model this session uses',
  options: [{ value: 'a', label: 'Model A' }],   // enum and multiselect only
  defaultValue: 'a',
  onChange: (value, target) => {},                // optional, serialized after persist
})

await s.get(sessionIdOrWorkspacePath)   // target: sessionId, workspace path, or ignored for global
await s.set(sessionIdOrWorkspacePath, 'b')
s.dispose()

Control types and their metadata:

typevalueregistration extras
toggleboolean
textstring, max 4000 chars
colorhex string
numberfinite numberoptional min, max
slidernumberrequired min, max, step
enumone option valuerequired options or a provider function
multiselectarray of option valuesrequired options or a provider function
keybindcombo string like Ctrl+Alt+K
pathabsolute directory string

For enum and multiselect, options may instead be a zero-argument function evaluated at describe time, so an option list can track a live library without re-registration. A stored value that vanished from the current list falls back to the first current option, never a value the UI cannot render.

Consumers (browser bundles)

Browser plugins inject granularSettingsClient and never touch fetch, relay topics, or wire shapes:

const gs = ctx.get('granularSettingsClient')
const cacheKey = sessionId || ''                        // '' is the sessionless context

gs.ensure(cacheKey)                                     // ask for one context's settings
gs.loaded(cacheKey)                                     // render gate, avoids default flash
gs.valueOf(cacheKey, 'my-plugin', 'session', 'model')   // value with default fallback
gs.set(cacheKey, 'my-plugin', 'session', 'model', 'b')  // optimistic write, same path the page uses
const off = gs.onChange(cacheKey, () => refetch())      // doorbell, reconnect, focus, set echo
gs.release(cacheKey)                                    // unmount drops one want, refcounted

One describe stream is shared by the page and every consumer: concurrent fetches coalesce, and a want that failed at boot, while host plugins were still loading, is retried on the next doorbell or focus.

Whole tabs can be contributed too, when rows are not enough:

const off = gs.registerTab({ id: 'my-tab', label: 'My Plugin', order: 40 }, MyComponent)

The component renders inside the page's scroll area and receives the settings-section props, useSessions among them. The built-in tabs sit at order 10, 20, 30.

Design

Namespacing is the contract. The namespace, the package name, is the identity used for storage, permissioning, and doorbell topics. owner is a display label only, so a renamed plugin can never split or merge boxes. Keys are unique only within their namespace, so generic names are fine.

Push is doorbells only. Every change, registration, and disposal publishes granular-settings//change with a null payload through dsh-event-relay. No values cross the wire as broadcast, and each plugin hears only its own namespace. Consumers refetch their own truth via describe. The one exception is point-to-point: the set route's HTTP response echoes the stored value to the caller, which the client applies directly, skipping a wasted refetch. Registration rings fix the boot race where the browser loads before host plugins register their settings.

Storage layout is itself a setting. The platform's own Plugin tab holds "Store workspace settings inside workspace", default on. While on, session and workspace values live under /.dsh/settings/ and travel with the folder. Off, they centralize under ~/.dsh/settings/workspaces//, keeping workspace trees clean. Reads and writes follow the active layout, so they cannot diverge. Existing files are not moved; flip back to read the old location again.

No migrations while pre-1.0. A store-shape change means deleting stale test data, a one-time manual reset, not carried conversion code. Reads are defensive: a non-conforming file reads as empty and the next write replaces it wholesale with a fresh revision.

Route roots are composition-level contracts: /granular-settings is taken.

Debugging

Read what the page reads, from a terminal:

curl 'http://127.0.0.1:3080/granular-settings/describe'
curl 'http://127.0.0.1:3080/granular-settings/describe?session='

Watch the doorbells live:

curl -N 'http://127.0.0.1:3080/relay/events?topics=granular-settings'

-N makes curl stream instead of buffering.

Plugins dependent on dsh-granular-settings

(none yet, list plugins that register settings or consume the client service here)