Files
deepseek-harness/docs/subsystems/settings.zh.md
T
Yichen Jiang 80ea5fcdf5 feat(settings): resolve namespaces per named scope
A settings namespace is now a kind; each registration is an instance
filed under the caller's nearest named scope (dsh-scope gains
createScope(..., { id }) and scopeIdOf). Instances resolve defaults →
composition base → global user section → scopes.<id>.<ns>. Global writes
fan out to every instance gated on its own resolved value; scoped writes
commit one instance. Revisions are per section so a scope nothing has
registered yet can still be written when the kind exists. describe(),
the controller verbs, and both events take an optional trailing scope.
Presets name their standing scopes preset/<id>; skill-filesystem is the
first consumer, resolving customSkillDirs per scope with live updates.
2026-09-04 03:03:43 +08:00

25 KiB
Raw Blame History

用户设置

English | 中文

dsh-settings 的用户设置 seam 持有一份按 namespace 分节的用户文档,并把每个已注册 namespace 解析为:schema 默认值,然后注册方的组合 base,最后用户分节。dsh-settings-file 这类提供方存储原始文档并推送外部编辑;消费方插件注册 schema 后读取或观察解析值。组合配置仍留在 cordis.yml——namespace 只承载用户可编辑子集。

来源:packages/settings/settings/src/index.ts

标识

namespace 命名用户文档中一个归插件所有的分节。brand 防止调用方将设置 namespace 与在包或进程之间传递的其他 id 混用;构造时校验小写 kebab-case 语法。

/** Nominal id of one registered settings namespace. */
type SettingsNamespace = Branded<'SettingsNamespace'>

注册

注册把 schemastery schema 绑定到调用方插件 fiber 上的 namespace——dispose(资源释放)该 fiber 即移除 namespace 及其观察者。options 携带组合层、owner 的生效时机,以及一个可选的、用于校验 schema 表达不了的约束的钩子。namespace 是一种设置的 kind;每次注册是它在调用方最近的具名 dsh-scope 作用域(agent preset 的 preset/<id>,或全局作用域)下的一个 instance,同一 kind 的每个注册者共享其 schema。instance 按顺序解析 schema 默认值、自己的 base、文档的全局分节与其作用域自己的分节。

/** Registration options beyond the namespace schema. */
interface SettingsRegisterOptions<T> {
  /** Composition-layer values resolved below the user layer (entry-config subset). */
  base?: Partial<T>
  /** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
  applies?: SettingsApplies
  /**
   * Reject a resolved section the owner could not act on, for constraints its
   * schema cannot express — a cross-field requirement, or one field's validity
   * depending on another's. Throwing here refuses the *write* that produced the
   * value, so a caller learns at `update`/`replace`/`mutate` instead of storing
   * something that would silently disable the owner.
   *
   * Kept separate from the schema because the schema is also what a
   * configuration surface renders and what an absent section resolves through;
   * folding a cross-field check into it would change both.
   *
   * Once the owner is registered, a stored section that fails this keeps the
   * namespace's last good value and warns, exactly as a schema failure does,
   * so an externally edited document cannot strand a running owner. At
   * registration there is no last good value yet, so a stored section that
   * already fails rejects the registration itself — again exactly as a schema
   * failure does.
   *
   * The check belongs to the namespace kind: the first registrant's check
   * judges every instance, because every instance is the same plugin.
   * @param value - the resolved section, schema-valid by construction.
   */
  validate?: (value: T) => void
}

validate 在 schema 接纳该值之后运行,因此它看到的默认值和组合 base 与 owner 实际看到的完全一致。dsh-llm-pi-ai 用它在写入处拒绝自己无法服务的提供方 profile,而不是先存下来、再让该 namespace 下每条路由失效。

applies 是 UI 提示而非机制:restart 的 owner 从不 watch,其值在构造期读取一次,配置界面可为待生效变更加标。

/** When a namespace's changes take effect for its owner. */
type SettingsApplies = 'live' | 'restart'

Owner scope

scope 是面向 owner 的句柄。update 把稀疏 patch 只合并进用户分节(绝不进 base);replace 整体替换分节,是删除/重置路径——替换中缺席的键重新继承 base 与 schema 默认值。同一 namespace 的写入按调用顺序串行,解析值是深冻结快照。

/** Owner-facing handle for one registered namespace. */
interface SettingsScope<T> {
  /** Current resolved value: schema defaults, then `base`, then the user layers. */
  get(): T
  /**
   * Observe committed changes to this namespace's resolved value. Invocations
   * of one callback run asynchronously, one at a time, in commit order; a
   * rejection is contained and logged like a sync throw. After the disposer
   * returns, no further invocation starts — one already queued is skipped;
   * one already started still settles, and service disposal waits for it.
   * @param callback - invoked after each commit with the next and previous values.
   * @returns the disposer removing this observer.
   */
  watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
  /**
   * Merge a partial patch into this registration's user section — the scope's
   * own section for a scoped registration — and persist it.
   * @param patch - plain-object patch over the user section; JSON-compatible data
   * only (non-JSON values reject with their path before anything persists).
   */
  update(patch: object): Promise<void>
  /**
   * Replace this registration's user section wholesale; absent keys re-inherit
   * the layers below (`replace({})` resets the section).
   * @param section - the complete next user section; JSON-compatible data only,
   * as for {@link update}.
   */
  replace(section: object): Promise<void>
}

描述符

describe() 为配置界面序列化每个已注册 namespace:schemastery 的 toJSON() 封装结构驱动 schema 渲染的表单,解析值填充表单,分离出的 base/user 层让表单按字段是否出现在 user 层标注「用户已覆盖」。describe({ redactSecrets: true })——每个对外传输接口都必须传入——从三层剥离 role('secret') 字段并枚举其 {path, set} slot,页面因此能渲染只写输入框而永远收不到机密值。

/** One registered namespace as surfaced to configuration UIs. */
interface SettingsDescriptor {
  // TODO(settings-namespace-vocabulary): Rename `ns` to `namespace` across the
  // public API, provider contract, implementations, tests, and consumers.
  /** The registered namespace. */
  ns: SettingsNamespace
  /** The named scope the descriptor resolves under; absent for the global scope. */
  scope?: SettingsScopeId
  /**
   * Whether an owner registered the namespace under this scope. False for a
   * scope described from the kind alone — a preset no session composed yet —
   * whose value then carries no composition `base`.
   */
  registered: boolean
  /** Serialized schemastery schema (`schema.toJSON()`). */
  schema: unknown
  /** Current resolved value. */
  value: unknown
  /**
   * Monotonic revision of the raw user section this descriptor was read at.
   * Send it back as `expectedRevision` on a write to refuse a stale one.
   */
  revision: number
  /** Registrant's composition `base` layer (detached), when one was declared. */
  base?: unknown
  /**
   * Raw user section from the stored document (detached), when one exists and
   * is well-formed; a field's presence here is what marks it user-overridden.
   * For a scoped descriptor this is the scope's own section, not the global one.
   */
  user?: unknown
  /**
   * For a scoped descriptor: the value the scope resolves without its own
   * user section — defaults, base, and the global section — so a surface can
   * tell a field the scope overrides from one it inherits.
   */
  inherited?: unknown
  /** Owner's declared effect timing. */
  applies: SettingsApplies
  /** Schema-declared secret positions; present only under `redactSecrets`. */
  secrets?: RedactedSecret[]
}

只持有脱敏 descriptor 的调用方无法安全地重建分节,因此删除改以路径 op 传递。每个 descriptor 还携带针对原始分节的 revision;写入可以把它作为 expectedRevision 送回,不再匹配的写入会被拒绝,而不会覆盖先落地的写入。

/**
 * One path-addressed edit to a namespace's user section. Path mutation exists
 * for a caller holding an INCOMPLETE view of the section — a configuration UI
 * reads the redacted descriptor, which by construction never received the
 * `role('secret')` fields. Such a caller can name the field it means without
 * restating the section: a wholesale `replace` rebuilt from a redacted
 * document silently deletes every secret the wire never returned.
 */
type SettingsPathOp =
  | { op: 'set'; path: readonly string[]; value: unknown }
  | { op: 'unset'; path: readonly string[] }
/** Options for {@link SettingsProvider.describe}. */
interface SettingsDescribeOptions {
  /**
   * Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
   * them in each descriptor's `secrets`. Every wire surface MUST pass this;
   * the verbatim default exists for same-process configuration UIs only.
   */
  redactSecrets?: boolean
  /**
   * Describe every namespace kind under this named scope instead of the
   * global scope. A kind with no registration under the scope is described
   * from the kind alone, `registered: false`.
   */
  scope?: string
}

变更提交

每次提交的变更——进程内写入或提供方观察到的外部编辑——在新值成为权威值之后发出 settings/updated (ns, next, prev, source),解析值深相等时绝不发出。source 标记区分两条入口路径。

/** Origin of one committed settings change. */
type SettingsUpdateSource = 'update' | 'provider'

原生文档操作

SettingsDocumentOpenValue 确认 settings/openSettingsDocument 已准备好 provider 持有的文档,并将其交给原生文本编辑器。AgentPresetDirectoryOpenValue 报告已完成的原生交接,或在桌面打开不可用时返回解析后的用户 preset 目录。两项操作都不接受由浏览器选择的 Host 路径。

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.settings — SettingsProvider (abstract seam)

Abstract settings service. Providers implement raw-document storage (load/persist) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the settings/updated commit event.

/**
 * Prepare the provider's user-editable document for a native editor. File
 * providers may materialize an absent document before returning its path;
 * non-file providers return undefined.
 * @returns the absolute local document path, or undefined for non-file storage.
 */
prepareDocument(): Promise<string | undefined>

/**
 * Register a namespace schema and receive its owner scope. The registration
 * is an effect on the calling plugin's fiber: disposing that fiber removes
 * the instance and its observers. An invalid stored section fails the
 * registration itself — the earliest point where the schema can judge it.
 *
 * The instance registers under the caller's nearest named scope: a plugin
 * mounted inside an agent preset resolves that preset's section over the
 * global one, and two presets mounting the same plugin hold two instances
 * of one kind. A second registrant of a namespace must carry the same
 * schema envelope; a different one is a different setting under a taken
 * name and fails loud.
 * @param ns - the namespace; a second registration under the same scope fails loud.
 * @param schema - schemastery schema resolving this namespace's value.
 * @param options - composition `base` layer and effect timing.
 * @returns the owner scope for reads, observation, and updates.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier or is reserved.
 */
register<const Namespace extends string, T>( ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, options?: SettingsRegisterOptions<T>, ): SettingsScope<T>

/**
 * Attach one optional-settings consumer to this provider. The consumer
 * registers its composition entry as the base layer while this provider is
 * present, then falls back to that entry if the provider detaches.
 * @param owner - consumer context whose unload suppresses fallback work.
 * @param ns - consumer-owned settings namespace.
 * @param schema - schema resolving the namespace.
 * @param entry - composition entry used as the base and fallback value.
 * @param hooks - source sink, change notification, and optional validation.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
 */
installSection<const Namespace extends string, T>( owner: Context, ns: Namespace & SettingsNamespaceInput<Namespace>, schema: z<T>, entry: T, hooks: SettingsSectionHooks<T>, ): void

/**
 * Describe every namespace kind for configuration surfaces, under the
 * global scope or one named scope: the composition `base` and raw user
 * layers so a form can mark which fields the user overrode (presence in
 * `user`) and what a reset returns to, and for a scoped read the
 * `inherited` value the scope's own section is layered over. A kind with
 * no instance under the requested scope is described from the kind alone.
 * @param options - redaction switch (wire surfaces must redact) and scope.
 * @returns one descriptor per namespace kind, in registration order.
 * @throws {TypeError} when `scope` is not a well-formed scope id.
 */
describe(options?: SettingsDescribeOptions): SettingsDescriptor[]

/**
 * Every named scope some namespace is registered under, in first-seen order.
 * @returns the scope ids.
 */
scopes(): SettingsScopeId[]

/**
 * Read one registered namespace's resolved value.
 * @param ns - the namespace to read.
 * @param scope - the named scope of the instance; the global instance when omitted.
 * @returns the resolved value, or `undefined` while unregistered under that scope.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
 */
get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>, scope?: string): unknown

/**
 * Merge a patch into one namespace's user section, validate the resolved
 * candidates, persist through the provider, then commit and emit. A
 * validation failure rejects before anything is persisted. Writes to one
 * section are serialized: concurrent updates apply in call order, each
 * merging over the previous write's committed section. A global write
 * re-resolves every instance of the kind; a scoped write only that scope's.
 * @param ns - the registered namespace to update.
 * @param patch - plain-object patch over the user section.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   section that moved past it rejects with {@link SettingsConflictError}.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
 */
async update<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, patch: object, expectedRevision?: number, scope?: string, ): Promise<void>

/**
 * Replace one namespace's user section wholesale, validate, persist, then
 * commit and emit. Keys absent from `section` fall back to the layers
 * below — this is the removal/reset path a merge-only patch cannot express
 * (`replace({})` re-inherits everything).
 * @param ns - the registered namespace to replace.
 * @param section - the complete next user section.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   section that moved past it rejects with {@link SettingsConflictError}.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
 */
async replace<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, section: object, expectedRevision?: number, scope?: string, ): Promise<void>

/**
 * Apply path-addressed edits to one namespace's user section, validate,
 * persist, then commit and emit. The ops are applied to the section as it
 * stands when the write reaches the front of the queue, so a caller never
 * has to restate fields it did not touch — and, crucially, cannot delete
 * fields it never saw. This is the write path for any caller holding a
 * redacted view; `replace` remains the wholesale reset.
 * @param ns - the registered namespace to edit.
 * @param ops - ordered path edits; later ops observe earlier ones.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   section that moved past it rejects with {@link SettingsConflictError}.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
 */
async mutate<const Namespace extends string>( ns: Namespace & SettingsNamespaceInput<Namespace>, ops: readonly SettingsPathOp[], expectedRevision?: number, scope?: string, ): Promise<void>

Source: packages/settings/settings/src/index.ts

ctx.settingsController — SettingsController

Host service backing the generated ctx.remote.settings namespace. Every remote read uses redactSecrets: true, so a role('secret') field cannot ride a response. Writes expose the settings service's merge, replacement, and path-addressed operations, and classify every provider refusal as settings/conflict or settings/rejected with the service's message.

/**
 * Describe every namespace kind for a configuration page: redacted layered
 * values plus the serialized schema the page renders its form from, under
 * the global scope or one named scope (an agent preset's `preset/<id>`).
 * @param scope - the named scope to describe; the global scope when omitted.
 * @returns provider writability, local-document presence, one view per
 * namespace kind, and every scope some namespace is registered under.
 * @throws RemoteError when no settings provider is mounted or the scope id is malformed.
 */
@Remote describe(scope?: string): SettingsDescribeValue

/**
 * Report whether this deployment can open an authored Agent preset directory natively.
 * @returns true when the matching open operation is available.
 */
@Remote canOpenAgentPresetDirectory(): boolean

/**
 * Merge a patch into one namespace's stored user section.
 * @param ns - namespace key to write.
 * @param patch - fields to merge into the user section.
 * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @returns the namespace's redacted view under that scope after the write.
 * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
 */
@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>

/**
 * Replace one namespace's stored user section wholesale.
 * @param ns - namespace key to write.
 * @param section - complete replacement user section.
 * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @returns the namespace's redacted view under that scope after the write.
 * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
 */
@Remote replace( ns: string, section: Record<string, JsonValue>, expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>

/**
 * Apply path-addressed edits to one namespace's user section, resolved against
 * the section as stored rather than against whatever the caller last read,
 * then answer with that namespace's new redacted view.
 * @param ns - namespace key to write.
 * @param ops - the edits to apply, in order.
 * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
 * @param scope - the named scope whose section to write; the global section when omitted.
 * @returns the namespace's redacted view under that scope after the write.
 * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write.
 */
@Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, scope?: string, ): Promise<SettingsNamespaceView>

/**
 * Materialize the provider-owned settings document and open it in a native text editor.
 * @param signal - caller lifetime; abort terminates preparation or the native command.
 * @returns confirmation after the native opener accepts the document.
 * @throws RemoteError when no document exists, preparation fails, or opening fails.
 */
@Remote async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue>

/**
 * Open one user-authored Agent preset directory or return its path when no native opener exists.
 * @param agentPreset - preset id resolved against Host-owned roots.
 * @param signal - caller lifetime; abort terminates the native command.
 * @returns an opened confirmation or the resolved directory for text display.
 * @throws RemoteError when the preset is missing, read-only, invalid, or cannot be opened.
 */
@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise<AgentPresetDirectoryOpenValue>

Source: packages/api/settings-controller/src/index.ts

settings/* events

settings/document-updated — emit

One registered namespace's RAW user section changed, whether or not the resolved value did. settings/updated is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches settings/updated.

/**
 * One registered namespace's RAW user section changed, whether or not the
 * resolved value did. `settings/updated` is the consumer-facing event and
 * stays deep-equal-gated; this one exists for configuration surfaces,
 * which must learn that a field went from inherited to overridden (same
 * resolved value, different meaning) and that their held revision is
 * stale. Listener containment matches `settings/updated`.
 * @param ns - the namespace whose stored section changed.
 * @param revision - the section's new revision.
 * @param scope - the named scope whose section changed; absent for the global section.
 * @mode emit
 */
'settings/document-updated'(ns: SettingsNamespace, revision: number, scope?: SettingsScopeId): void

Source: packages/settings/settings/src/types.ts

settings/updated — emit

Committed change to one registered namespace's resolved value. Emitted after the provider persisted (for update) or published (provider) the change; never emitted when the resolved value is deep-equal. Listener failures are contained and logged — a sync throw and an async rejection alike — except INVARIANT-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.

/**
 * Committed change to one registered namespace's resolved value. Emitted
 * after the provider persisted (for `update`) or published (`provider`)
 * the change; never emitted when the resolved value is deep-equal.
 * Listener failures are contained and logged — a sync throw and an async
 * rejection alike — except `INVARIANT`-coded failures, which rethrow
 * after every listener ran; that rethrow reaches the emitter only from
 * synchronous listeners, so invariant checks on this event must not be
 * async functions.
 * @param ns - the namespace whose resolved value changed.
 * @param next - the new resolved value.
 * @param prev - the previous resolved value.
 * @param source - whether the change entered through `update()` or the provider.
 * @param scope - the named scope whose registration changed; absent for the global scope.
 * @mode emit
 */
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource, scope?: SettingsScopeId): void

Source: packages/settings/settings/src/types.ts