fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
Release / release (push) Successful in 6s

0.1.7-rc.2 moved four things this bundle was built on, and every one of them
failed quietly rather than loudly, so the plugin looked installed and did
nothing:

  * `ctx.settings.installSection(...)` is gone. The call threw into a
    `try/catch` that only logged a warning, so the settings namespace was never
    registered and `configure` had nowhere to persist. An entry's exported
    Config IS the form now; the namespace is the Loader entry id the bundle's
    patch declares, and `ctx.settings.configure({ auto: false })` is what claims
    that entry's own page.

  * `settingsScope` and the `settings.plugin.item` slot are gone. The card
    registered into both, so it never rendered — the tab draws the intersection
    of served namespaces and registered cards, and that intersection was empty.
    It now registers into `plugins.bundle.config` keyed by the bundle's package
    name, over the `configForms` service, and uses the framework's own form
    chrome (`SettingsFormModel` / `SettingsForm` / `SettingsValueField` /
    `SettingsSecretField`) instead of the hand-rolled equivalent. A save is one
    revision-fenced write.

  * `ctx.shell.run(spec)` is gone. Every action that runs a command threw
    `shell.run is not a function`; the seam is now
    `await ctx.shell.execute(ctx.shell.resolve(request)).result()`.

  * A job's `owner` is a session id, not the agent object. The wrong owner
    registered a job no session could collect or cancel, and the producer's
    `done` resolved `output` where the registry reads `result`, so a finished
    job handed back nothing.

Two conformance gaps went with them: a Config field that is not marked
`volatile()` is invisible to every settings UI — the form projection keeps only
volatile fields, and an entry with no form is dropped from `settings.describe`
entirely — and `embeddingApiKey` was a plain field, so the key rode every
settings read back to the browser. It is `role('secret')` now: the browser is
told whether a key is stored and never receives the key.

Also declares the `@deepseek-ai/dsh-*` peerDependencies the compatibility gate
reads, so an incompatible runtime refuses this plugin at startup instead of
half-loading it.

Verified against a real 0.1.7-rc.2 boot: `settings.describe` serves the entry
with `autoGenerate: false`, the key redacted and reported only as `set: true`,
and no startup diagnostics. The host mock in the tests now has no `run` and no
`installSection`, so those seams are contract-tested rather than assumed, and
the browser tests read the installed harness's own slot catalog and package
exports — a rename fails a test instead of quietly emptying a page.
This commit is contained in:
dsh-slopo agent committed 2026-09-26 06:38:16 +01:00
1 parent ffefc42e01
commit 1b2ab3613f
8 files changed
+1549 -689

No files matched your search

+125 -67
View File
@@ -7,8 +7,11 @@
* All three are user-invocable and deliberately NOT model-invocable,
* matching upstream: every one of these commands can spend minutes
* embedding, so the human decides when.
* 2. A `dsh-slopo` settings namespace on `ctx.settings`, so the embedding
* backend is editable at runtime instead of baked into a YAML layer.
* 2. The `dsh-slopo` configuration: this module's own exported `Config`, which
* the framework projects onto the Loader entry that composed it and
* addresses by that entry's id. Nothing is registered for it — a section
* assembled from a schema cannot disagree with the schema — and the page
* that edits it lives in `client/client.js`.
* 3. One `slopo` tool that resolves the CLI, materialises the config, and
* runs the long work as a background job.
*
@@ -17,7 +20,7 @@
* all. Putting `settings` in the top-level inject would make the tool and the
* skills disappear on any profile without a settings provider, which is a
* silent whole-plugin failure for an optional capability. The optional form
* degrades to the composition config instead.
* simply has nowhere to persist a `configure` and says so.
*
* Plain ESM, no build step — deliberately. `dsh plugin add git+...` fetches the
* source tree, not build output, and a `prepare` script would make every user
@@ -52,6 +55,7 @@ import {
installEnv,
lastLogError,
commandLine,
runShell,
slopoArgs,
uvInstallArgs,
} from './lib/slopo.js'
@@ -107,38 +111,49 @@ const SETTING_KEYS = [
* `embedding_model` and `embedding_dimensions`, and guessing an embedding
* backend would mean guessing where money is spent. Unset means unset, and the
* tool says so.
*
* Every field is marked `volatile()`, which is what puts it on the settings
* page: the framework's form projection keeps only the fields whose nearest
* volatile ancestor makes them editable without remounting the plugin, and an
* entry with no such field is dropped from `settings.describe` entirely. An
* unmarked field would still be *configurable* through the profile patch — it
* would simply be invisible to every settings UI.
*
* `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.
*/
export const Config = Schema.object({
/** Explicit slopo executable. Empty means autodiscover. */
binary: Schema.string().default(''),
binary: Schema.string().default('').volatile(),
/** Install slopo into $DSH_HOME with uv when it is missing. */
autoInstall: Schema.boolean().default(true),
autoInstall: Schema.boolean().default(true).volatile(),
/** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */
embeddingModel: Schema.string().default(''),
embeddingModel: Schema.string().default('').volatile(),
/** Output dimensions, which must match the model. */
embeddingDimensions: Schema.number().default(0),
embeddingDimensions: Schema.number().default(0).volatile(),
/** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */
embeddingApiBase: Schema.string().default(''),
embeddingApiBase: Schema.string().default('').volatile(),
/** Provider key. Passed to slopo in its environment, never written to disk. */
embeddingApiKey: Schema.string().default(''),
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
/** Extra LiteLLM embedding params, as a JSON object string. */
embeddingParamsJson: Schema.string().default('{}'),
embeddingParamsJson: Schema.string().default('{}').volatile(),
/** Code to index. Empty means the session's working directory. */
sourceDir: Schema.string().default(''),
sourceDir: Schema.string().default('').volatile(),
/** Extra gitignore-style paths to skip, on top of the defaults. */
sourceDirExclude: Schema.array(String).default([]),
sourceDirExclude: Schema.array(String).default([]).volatile(),
/** Exclude vendored dependencies and build output. Off means slopo sees everything. */
useDefaultExcludes: Schema.boolean().default(true),
useDefaultExcludes: Schema.boolean().default(true).volatile(),
/** Refuse to embed more than this many units without being asked. 0 removes the limit. */
maxIndexUnits: Schema.number().default(5000),
maxIndexUnits: Schema.number().default(5000).volatile(),
/** Ignore file. Empty means inside the per-workspace state directory. */
ignoreFile: Schema.string().default(''),
ignoreFile: Schema.string().default('').volatile(),
/** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */
stateDir: Schema.string().default(''),
stateDir: Schema.string().default('').volatile(),
/** Milliseconds between embedding requests, for rate-limited providers. */
embeddingRequestDelay: Schema.number().default(0),
embeddingRequestDelay: Schema.number().default(0).volatile(),
/** Cooperative budget for a foreground slopo run. */
timeoutMs: Schema.number().default(600_000),
timeoutMs: Schema.number().default(600_000).volatile(),
})
/** Resolve the harness home, preferring the harness's own resolver. */
@@ -338,20 +353,29 @@ export function apply(ctx, config) {
const logger = ctx.logger('dsh-slopo')
const dshHome = dshHomeOf(ctx)
// The authoritative settings source. Starts as the composition config and is
// replaced by the settings scope's thunk once a provider attaches; if none
// ever does, everything below still works off the composition layer.
let readSettings = () => config
// `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.
let writeSettings
// The settings namespace IS this plugin's Config. The framework projects the
// exported schema onto the Loader entry that composed it and addresses it by
// that entry's id, which the bundle's patch declares as `dsh-slopo` — the
// same string the browser card binds to. Nothing is registered here any more:
// 0.1.7-rc.2 removed `ctx.settings.installSection`, because a section
// assembled from a schema cannot disagree with the schema.
//
// `configure({ auto: false })` records that this entry ships its own page
// (`client/client.js` registers one onto the Plugins page), so the deployment
// must not also generate one for the same entry.
ctx.inject(['settings'], (settingsCtx) => {
try {
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
setSource: (current) => {
readSettings = current
},
onChange: () => {},
})
settingsCtx.effect(
() => settingsCtx.settings.configure({ auto: false }, ctx.fiber),
'dsh-slopo: settings page policy',
)
writeSettings = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch)
} catch (error) {
logger.warn('dsh-slopo: settings namespace unavailable: %s', error?.message ?? error)
@@ -360,8 +384,15 @@ export function apply(ctx, config) {
ctx.skills.registerProvider(() => skillsProvider)
/** The slopo executable, installing it first when allowed. */
async function ensureSlopo(settings, run) {
/**
* The slopo executable, installing it first when allowed.
*
* `installInto` is the directory the install runs in. It is deliberately NOT
* the workspace: `uv tool install` has nothing to do with the project, and a
* shell that must `cd` into a workspace that does not exist yet fails before
* `uv` is ever reached.
*/
async function ensureSlopo(settings, run, installInto) {
const configured = typeof settings.binary === 'string' ? settings.binary.trim() : ''
if (configured !== '' && existsSync(configured)) return configured
const found = findSlopo(dshHome)
@@ -369,7 +400,11 @@ export function apply(ctx, config) {
if (settings.autoInstall !== true) {
return { error: 'SLOPO_NOT_INSTALLED', remedy: 'Set `autoInstall`, or install slopo yourself and set `binary`.' }
}
const install = await run(commandLine(['uv', ...uvInstallArgs()]), { env: installEnv(dshHome), timeoutMs: 900_000 })
const install = await run(commandLine(['uv', ...uvInstallArgs()]), {
env: installEnv(dshHome),
timeoutMs: 900_000,
workdir: installInto,
})
if (install.exitCode !== 0) {
return {
error: 'SLOPO_INSTALL_FAILED',
@@ -446,7 +481,7 @@ export function apply(ctx, config) {
},
async execute(args, exec) {
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
const settings = readSettings() ?? config
const settings = config
const paths = workspacePaths(dshHome, cwd, settings)
const shell = ctx.get('shell')
// Resolved once, up front, so `status` can probe without a second
@@ -484,18 +519,16 @@ export function apply(ctx, config) {
let slopoVersion
if (binary !== undefined && shell !== undefined) {
try {
const versionResult = await shell.run(
shell.resolve({
command: commandLine([binary, '--version']),
workdir: paths.cwd,
timeoutMs: 30_000,
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
}),
)
const versionResult = await runShell(shell, {
command: commandLine([binary, '--version']),
workdir: paths.cwd,
timeoutMs: 30_000,
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
})
slopoVersion =
versionResult.exitCode === 0
? (versionResult.stdout?.text ?? '').trim().split('\n')[0]
: `exit code ${versionResult.exitCode}: ${(versionResult.stderr?.text ?? '').trim().slice(0, 200)}`
? versionResult.stdout.text.trim().split('\n')[0]
: `exit code ${versionResult.exitCode}: ${versionResult.stderr.text.trim().slice(0, 200)}`
} catch (error) {
slopoVersion = `could not run: ${error?.message ?? error}`
}
@@ -642,25 +675,34 @@ export function apply(ctx, config) {
})
}
/** Run one command through the shell seam. */
const run = async (command, { env, timeoutMs, signal } = {}) => {
const result = await shell.run(
shell.resolve({
command,
workdir: paths.cwd,
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
...(signal === undefined ? {} : { signal }),
// Merges over a parent-derived base, so PATH and HOME survive.
...(env === undefined ? {} : { env }),
// Omitting this substitutes the deployment default; see above.
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
}),
)
/**
* Run one command through the shell seam.
*
* `runShell` returns the seam's own result; everything below wants the
* flattened strings, so the adaptation happens once, here.
*
* `workdir` is overridable because installing slopo has nothing to do
* with the workspace: the workspace may not even exist yet on a fresh
* session, and a shell that cannot `cd` there fails before `uv` is
* reached. The plugin's own state directory is always a real directory
* by the time an install runs.
*/
const run = async (command, { env, timeoutMs, signal, workdir } = {}) => {
const result = await runShell(shell, {
command,
workdir: workdir ?? paths.cwd,
timeoutMs: timeoutMs ?? (Number(settings.timeoutMs) || 600_000),
...(signal === undefined ? {} : { signal }),
// Merges over a parent-derived base, so PATH and HOME survive.
...(env === undefined ? {} : { env }),
// Omitting this substitutes the deployment default; see above.
...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
})
return {
exitCode: result.exitCode,
stdout: result.stdout?.text ?? '',
stderr: result.stderr?.text ?? '',
truncated: Boolean(result.stdout?.truncated || result.stderr?.truncated),
stdout: result.stdout.text,
stderr: result.stderr.text,
truncated: result.stdout.truncated || result.stderr.truncated,
timedOut: Boolean(result.timedOut),
}
}
@@ -697,14 +739,20 @@ export function apply(ctx, config) {
})
}
const binary = await ensureSlopo(settings, run)
// The state directory and the config file come FIRST, before the
// executable is resolved. The config is a pure function of the
// settings — nothing about it needs slopo to exist — and writing it
// early means a run that stops at "not installed" still leaves the
// resolved configuration on disk to look at. It also gives the install
// below a real working directory to run in.
await mkdir(paths.stateDir, { recursive: true })
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
const binary = await ensureSlopo(settings, run, paths.stateDir)
if (typeof binary !== 'string') {
return foreground({ ok: false, exitCode: null, errorCode: binary.error, message: binary.remedy, remedy: binary.detail })
}
await mkdir(paths.stateDir, { recursive: true })
await writeFile(paths.configPath, renderSlopoConfig(slopoConfig), 'utf8')
const env =
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
@@ -765,9 +813,19 @@ export function apply(ctx, config) {
const jobId = jobs.start({
kind: 'slopo',
label: `slopo ${args.action}`,
...(exec.agent === undefined ? {} : { owner: exec.agent }),
// Ownership is fenced by SESSION ID, not by the agent object: the
// registry cancels and awaits the job when that session's agent is
// disposed, and it looks the id up itself. Passing `exec.agent`
// here registered a job no session could collect or stop.
...(exec.agent === undefined ? {} : { owner: exec.agent.id }),
run: () => {
const controller = new AbortController()
// `done` resolves to a JobOutcome: `{ status, detail, result }`.
// The report rides `result`, which the registry hands to the
// model on its first read after settlement — read it back with
// job_output. (The field is `result`, not `output`: the ring
// carries a job's stream, and `result` carries the return value
// of one whose product is a value rather than a stream.)
const done = (async () => {
const { refused, counted, outcome } = await runScoped(controller.signal)
const logDetail = await readLogTail(paths.logFile)
@@ -775,17 +833,17 @@ export function apply(ctx, config) {
return {
status: 'failed',
detail: 'SCOPE_TOO_LARGE',
output: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
}
}
if (outcome.exitCode === 0) {
return { status: 'completed', detail: 'exit code: 0', output: outcome.stdout.trim() || 'No duplicates found' }
return { status: 'completed', detail: 'exit code: 0', result: outcome.stdout.trim() || 'No duplicates found' }
}
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
return {
status: 'failed',
detail: explained.code,
output: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
}
})()
return { cancel: () => controller.abort(), done }