settings.zh.md 25 KB

用户设置

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 no session composed yet, whose value carries no `base` of its own
   * and resolves over the global instance's composition when one is registered.
   */
  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
   * over the global instance's composition, else 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