fix: read the settings values, not the references they arrive as

Every field is `volatile()`, which is what puts the settings card on the
Plugins page — and what makes the resolved config carry a reference per field
rather than a value. Every read was a plain-value test (`typeof x === 'string'`,
`Number(x)`, `x !== true`), so all of them were false and the plugin behaved as
if nothing had been configured.

The card displayed and persisted correctly the whole time, because the settings
wire unwraps references for display; only the plugin's own reads were broken.
Reads now go through `configValue()`, and the tool takes a fresh snapshot per
call, which is what lets an edit reach a running instance.
This commit is contained in:
dsh-slopo agent committed 2026-09-26 07:13:36 +01:00
1 parent 9ebd484e60
commit 8f68ef4e37
4 files changed
+231 -14

No files matched your search

+74 -12
View File
@@ -119,6 +119,14 @@ const SETTING_KEYS = [
* unmarked field would still be *configurable* through the profile patch — it
* would simply be invisible to every settings UI.
*
* `volatile()` has a second consequence, and it is the one that bites: the
* resolved config does not carry VALUES for these fields. Each arrives as a
* reference object with a `get()`, because that is what lets a settings write
* land in the running instance without re-applying the plugin. So a plain-value
* read (`typeof config.embeddingModel === 'string'`) is false for every field,
* and the plugin silently behaves as if nothing were configured. Every read
* goes through {@link configValue} or {@link settingsSnapshot}.
*
* `embeddingApiKey` additionally carries `role('secret')`, so the settings
* wire never carries it: every remote read is redacted (`secrets` reports only
* whether a value is set) and the browser sends the key one way, on write.
@@ -156,6 +164,40 @@ export const Config = Schema.object({
timeoutMs: Schema.number().default(600_000).volatile(),
})
/**
* Read one configuration field.
*
* A volatile field is a reference in the running config, not a plain value; a
* non-volatile one (or a config object a test handed straight to `apply`) is
* the value itself. Both are accepted so the same path serves a real profile
* and a direct call. Duck-typed on `get` rather than importing cosmokit's
* `isVolatile`, because the only thing this plugin needs to know about a
* reference is how to read it.
*
* @param {unknown} value a field of the resolved config
* @returns {unknown} the current value
*/
export function configValue(value) {
return value !== null && typeof value === 'object' && typeof value.get === 'function' ? value.get() : value
}
/**
* A plain snapshot of the whole settings section.
*
* Taken per tool call rather than cached, because that is exactly what a
* volatile field buys: the running instance's references are written in place
* when the document changes, so the next snapshot sees the new value without
* the plugin being remounted.
*
* @param {Record<string, any>} config the resolved composition config
* @returns {Record<string, any>} every declared field, unwrapped
*/
export function settingsSnapshot(config) {
const snapshot = {}
for (const key of Object.keys(config ?? {})) snapshot[key] = configValue(config[key])
return snapshot
}
/** Resolve the harness home, preferring the harness's own resolver. */
function dshHomeOf(ctx) {
try {
@@ -170,8 +212,16 @@ function dshHomeOf(ctx) {
return resolveDshHome()
}
/** Every path one workspace's slopo state occupies. */
export function workspacePaths(dshHome, cwd, settings) {
/**
* Every path one workspace's slopo state occupies.
*
* `config` may be the live composition config — whose fields are all volatile
* references — or the plain snapshot a tool call already took of it. Both are
* accepted, because this is exported and the next caller cannot be assumed to
* know the difference; a plain value passes through `settingsSnapshot` intact.
*/
export function workspacePaths(dshHome, cwd, config) {
const settings = settingsSnapshot(config)
const stateDir =
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
? settings.stateDir.trim()
@@ -220,9 +270,13 @@ function embeddingParams(settings, problems) {
* The names differ on purpose: dsh settings are camelCase by convention while
* slopo's config file is snake_case, and slopo treats an unknown key as a hard
* error, so this mapping is the only place a name can go wrong.
*
* `config` is accepted in either shape for the same reason `workspacePaths`
* is: exported, so called with a live volatile config or with a plain one.
*/
export function toSlopoConfig(settings, paths, problems = []) {
const config = {
export function toSlopoConfig(config, paths, problems = []) {
const settings = settingsSnapshot(config)
const mapped = {
source_dir: paths.sourceDir,
source_dir_exclude: effectiveExcludes(settings),
db_file: paths.dbFile,
@@ -236,7 +290,7 @@ export function toSlopoConfig(settings, paths, problems = []) {
embedding_params: embeddingParams(settings, problems),
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
}
const { config: normalized, problems: numericProblems } = normalizeConfig(config)
const { config: normalized, problems: numericProblems } = normalizeConfig(mapped)
problems.push(...numericProblems)
delete normalized.embedding_api_key
return normalized
@@ -347,17 +401,23 @@ export const inject = ['tools', 'skills']
* Register the skills, the settings namespace, and the tool.
*
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {Record<string, unknown>} config validated composition config
* @param {Record<string, unknown>} config validated composition config, whose
* fields are volatile references rather than values
*/
export function apply(ctx, config) {
const logger = ctx.logger('dsh-slopo')
const dshHome = dshHomeOf(ctx)
// `config` is the live configuration, not a snapshot of the composition
// layer: a settings write rewrites this entry's patch and the Loader
// re-applies the plugin with the new values, so every read below sees the
// deployment's current answer. There is deliberately no cached copy — a cache
// is exactly what a reload invalidates.
// The authoritative settings source: a plain snapshot of the running config,
// taken per call.
//
// `config` holds LIVE REFERENCES, not values. Every field is `volatile()`, so
// a settings write does NOT re-apply this plugin: the Loader writes the new
// value into the reference this instance already holds (`_commitVolatile`),
// and that is the whole point of the marker. It is also why nothing here is
// cached — a snapshot taken once at apply time would be frozen at the
// composition's values and would never see an edit.
const readSettings = () => settingsSnapshot(config)
let writeSettings
// The settings namespace IS this plugin's Config. The framework projects the
@@ -481,7 +541,9 @@ export function apply(ctx, config) {
},
async execute(args, exec) {
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const settings = config
// Fresh per call, so a settings edit made while this tool is loaded is
// picked up by the very next call — see `readSettings` above.
const settings = readSettings()
const paths = workspacePaths(dshHome, cwd, settings)
const shell = ctx.get('shell')
// Resolved once, up front, so `status` can probe without a second