docs(agent-notes): record the web plugin configuration decision

Also folds the duplication the three controls had grown: the draft-and-commit
input both editable fields render is now one component, and the two sibling
executors' identical import surface is marked as the deliberate mirror their
READMEs already describe.
This commit is contained in:
Yichen Jiang
2026-08-10 20:06:18 +08:00
parent 114af09aae
commit 1b473be886
7 changed files with 164 additions and 51 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md
2026-08-10-web-plugin-configuration.md: 146dac0684a783b170614c3320485dd8f2127a66
2026-08-10-web-plugin-configuration.zh.md: b0a1b1b91fc5a1bc0e3162c3e72d28ea057d44da
@@ -0,0 +1,43 @@
# Agent Note: Plugin configuration in the web settings page
Status: implemented
English | [中文](2026-08-10-web-plugin-configuration.zh.md)
## Problem
Everything a plugin can be configured with lived in `cordis.yml`. A user who wanted a longer shell timeout, a different search endpoint, or fewer parallel tool calls had to find the composition file, know its shape, and restart — while the Models page had shown for months that a settings namespace can be edited from the browser and take effect immediately.
The seam that made the Models page possible was already general: any plugin may register a namespace, and `settings.describe` serves its schema, its layers, and its revision. What was missing was on the two ends. No plugin outside the LLM adapters and the permission service had registered one, and there was no surface for a namespace that is not a model provider.
## Decision
Three host-plane plugins register their own settings namespace, and one browser-side section renders whatever the deployment exposes.
**Layering, unchanged.** A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its `cordis.yml` entry as the `base` and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on `graceMs`, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command.
**The shell namespace names the capability, not an implementation.** `BASH_SETTINGS_NAMESPACE` is exported by `@deepseek-ai/dsh-bash` because a host composes exactly one provider of `ctx.bash`: the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration. Both families therefore register the same namespace with their own schema and entry without ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both — schemastery objects preserve keys the active schema does not declare.
**A section is a subset when the plugin config is bigger than what a user owns.** `agent-loop` exposes only `maxParallelToolCalls`; its `agents` array is consumed once when the service starts, so a stored change there could only look like it had an effect.
**The provider projects, rather than captures.** `web-search-deepseek` hands its provider a thunk instead of an options value, so an endpoint or model change reaches the next search without re-registering the provider — which would make the web seam's provider selection observable to the user as a flicker.
**Exposure stays a Host allowlist.** The three namespaces join `WEB_SETTINGS_NAMESPACES`; registration alone still never crosses the transport, and a namespace absent from that list answers `settings-not-exposed` exactly as an unregistered one does.
**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it.
## Alternatives considered
- **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim.
- **A generic schema-driven form renderer.** Declined again for the reason recorded in the web-config-plane note: field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package.
- **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design.
- **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows.
- **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names.
## Consequences
A user edits the shell's command timeout and output cap, the agent loop's parallel tool-call cap, and the search provider's key, endpoint, and per-request budget from the settings page, with each field marking whether they set it and offering a reset.
Two costs are real. Adding a fourth plugin still requires an entry in the apiproxy allowlist, so the page's reach is a Host decision rather than a plugin's. And the plugins the web deployment moved into the agent plane — the file tools, the skills, compaction, the todo tool — appear nowhere here, which is most of what a user might expect to find; their configuration remains the preset editor's.
The bash and pwsh executors now expose `config` as a getter over a source thunk rather than a readonly field. Every read site was already per-call, so nothing else changed, but a subclass that captured `this.config` at construction would silently pin the composition entry.
@@ -0,0 +1,43 @@
# Agent Note: Plugin configuration in the web settings page
Status: implemented
[English](2026-08-10-web-plugin-configuration.md) | 中文
## 问题
插件的一切可配置项都只存在于 `cordis.yml`。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明:settings 命名空间可以在浏览器里编辑并立即生效。
支撑 Models 页的那条 seam 本就是通用的:任何插件都可以注册命名空间,`settings.describe` 会提供它的 schema、分层与 revision。缺的是两端。除 LLM 适配器与权限服务外,没有插件注册过命名空间;而对于非模型提供方的命名空间,也没有任何表层。
## 决策
三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧分区渲染该部署所暴露的一切。
**分层不变。** 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 `cordis.yml` 条目作为 `base` 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、`graceMs` 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。
**shell 命名空间命名的是能力,而非某个实现。** `BASH_SETTINGS_NAMESPACE``@deepseek-ai/dsh-bash` 导出,因为一个宿主只组装一个 `ctx.bash` 提供方:win32 层会把 POSIX 行换成 pwsh 行,而同时挂载两者会因服务重复注册在加载期失败。因此两个家族都能用自己的 schema 与条目注册同一个命名空间而永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析——schemastery 对象会保留当前 schema 未声明的键。
**当插件配置大于用户所拥有的部分时,分节就是一个子集。** `agent-loop` 只暴露 `maxParallelToolCalls`;它的 `agents` 数组在服务启动时被消费一次,所以存储在那里的变更只会看起来生效。
**提供方按次投影,而不是固化。** `web-search-deepseek` 交给提供方的是一个 thunk 而非 options 值,因此端点或模型的变更无需重新注册提供方即可作用于下一次搜索——重新注册会让 web seam 的提供方选择以闪断的形式被用户看到。
**暴露仍是 Host 的白名单。** 这三个命名空间加入 `WEB_SETTINGS_NAMESPACES`;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 `settings-not-exposed`
**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。
## 备选方案
- **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。
- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 web-config-plane 笔记所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。
- **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。
- **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。
- **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。
## 影响
用户可以在设置页编辑 shell 的命令超时与输出上限、agent 循环的并行工具调用上限,以及搜索提供方的密钥、端点与单次请求预算,每个字段都标注是否由自己设定,并提供重置。
有两项真实代价。加入第四个插件仍需要在 apiproxy 白名单里添一条,因此本页的覆盖面是 Host 的决定而非插件的决定。而 web 部署移入 agent 平面的那些插件——文件工具、技能、压缩、todo 工具——在这里一个都不出现,而它们恰恰是用户最可能期待找到的;它们的配置仍归 preset 编辑器。
bash 与 pwsh 执行器现在把 `config` 暴露为 source thunk 之上的 getter,而不再是 readonly 字段。所有读取点本就是按次读取,因此别无变化;但若某个子类在构造期捕获 `this.config`,就会悄然把组装条目钉死。
+1 -1
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 14b6676865e536a15371e26f8f696e7beacfb033
config-catalog.md: 1e31ddd9ca3fa9fdf6cbcf2969b516fbfde39e49
config-catalog.zh.md: 93e02dc1691dc830e9aacea598e1e5e9774b9c6e
+1 -1
View File
@@ -1235,7 +1235,7 @@ export interface Config {
}
```
Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts)
Source: [`packages/bash/pwsh-local/src/index.ts:58`](../packages/bash/pwsh-local/src/index.ts)
## `@deepseek-ai/dsh-pwsh-sandbox`
+3
View File
@@ -13,6 +13,8 @@
* @module @deepseek-ai/dsh-pwsh-local
*/
/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by
design (see this package's README), so the two import the same seam surface */
import { Context } from 'cordis'
import z from 'schemastery'
import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -20,6 +22,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
/* jscpd:ignore-end */
import { resolvePwshPath } from './resolve.ts'
/* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */
@@ -73,6 +73,46 @@ function useDraft(value: string): [string, (next: string) => void] {
return [draft, setDraft]
}
/** Blur the input so its own blur handler is the single commit path. */
function commitOnEnter(event: KeyboardEvent<HTMLInputElement>): void {
if (event.key === 'Enter') event.currentTarget.blur()
}
/**
* The text input both editable fields render: a draft seeded from the
* authoritative text, committed on blur and on Enter.
*/
function DraftInput(props: {
/** Stable id associating the label with this control. */
id: string
/** Authoritative text the draft re-seeds from. */
value: string
/** Disables editing. */
disabled: boolean
/** Placeholder shown while the draft is empty. */
placeholder?: string | undefined
/** Hints a numeric keypad without narrowing the value type. */
numeric?: boolean | undefined
/** Settle the draft; the returned text replaces it (a rejected draft restores the value). */
onSettle: (draft: string, restore: (text: string) => void) => void
}) {
const [draft, setDraft] = useDraft(props.value)
return (
<input
id={props.id}
className={css.input}
type="text"
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
value={draft}
placeholder={props.placeholder ?? ''}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={() => { props.onSettle(draft, setDraft) }}
onKeyDown={commitOnEnter}
/>
)
}
/** A whole-number field committed on blur or Enter. */
export function NumberField(props: FieldProps & {
/** Current effective value. */
@@ -80,31 +120,22 @@ export function NumberField(props: FieldProps & {
/** Commit a parsed value; a draft that is not a finite number is discarded. */
onCommit: (next: number) => void
}) {
const [draft, setDraft] = useDraft(String(props.value))
const commit = () => {
const parsed = Number(draft)
if (draft.trim() === '' || !Number.isFinite(parsed)) {
setDraft(String(props.value))
return
}
if (parsed === props.value) return
props.onCommit(parsed)
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<FieldFrame {...props}>
<input
<DraftInput
id={props.id}
className={css.input}
type="text"
inputMode="numeric"
value={draft}
value={String(props.value)}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
numeric
onSettle={(draft, restore) => {
const parsed = Number(draft)
if (draft.trim() === '' || !Number.isFinite(parsed)) {
restore(String(props.value))
return
}
if (parsed === props.value) return
props.onCommit(parsed)
}}
/>
</FieldFrame>
)
@@ -119,27 +150,18 @@ export function TextField(props: FieldProps & {
/** Commit the trimmed draft. */
onCommit: (next: string) => void
}) {
const [draft, setDraft] = useDraft(props.value)
const commit = () => {
const next = draft.trim()
if (next === props.value) return
props.onCommit(next)
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<FieldFrame {...props}>
<input
<DraftInput
id={props.id}
className={css.input}
type="text"
value={draft}
placeholder={props.placeholder ?? ''}
value={props.value}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
placeholder={props.placeholder}
onSettle={(draft) => {
const next = draft.trim()
if (next === props.value) return
props.onCommit(next)
}}
/>
</FieldFrame>
)
@@ -159,15 +181,6 @@ export function SecretField(props: Omit<FieldProps, 'overridden' | 'onReset'> &
onCommit: (next: string) => void
}) {
const [draft, setDraft] = useState('')
const commit = () => {
const next = draft.trim()
if (next === '') return
setDraft('')
props.onCommit(next)
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<div className={css.field}>
<div className={css.head}>
@@ -184,8 +197,13 @@ export function SecretField(props: Omit<FieldProps, 'overridden' | 'onReset'> &
value={draft}
disabled={props.disabled}
onChange={(event) => { setDraft(event.target.value) }}
onBlur={commit}
onKeyDown={onKeyDown}
onBlur={() => {
const next = draft.trim()
if (next === '') return
setDraft('')
props.onCommit(next)
}}
onKeyDown={commitOnEnter}
/>
<p className={css.hint}>{props.hint}</p>
</div>