Files
dsh-slopo/index.js
T
dsh-slopo agent 1b2ab3613f
Release / release (push) Successful in 6s
fix!: port the settings page and shell seam to dsh 0.1.7-rc.2
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.
2026-09-26 06:38:16 +01:00

890 lines
39 KiB
JavaScript

/**
* dsh-slopo — slopo as a DeepSeek Harness bundle.
*
* Three contributions:
*
* 1. Three skills on `ctx.skills`, ported from slopo's own agent configs.
* 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. 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.
*
* Why `ctx.inject(['settings'], ...)` rather than `inject: ['settings']`: a
* Cordis plugin with an unsatisfied required dependency never runs `apply` at
* 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
* 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
* allowlist a build before the first install succeeds.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineTool } from '@deepseek-ai/dsh-tools'
import Schema from '@deepseek-ai/schemastery'
import { missingRequired, normalizeConfig, renderSlopoConfig } from './lib/config.js'
import { probeEmbeddingEndpoint } from './lib/probe.js'
import { toJsonValue } from './lib/json.js'
import {
budgetRemedy,
checkBudget,
effectiveExcludes,
parseIndexOutput,
readIndexCounts,
} from './lib/scope.js'
import {
resolveDshHome,
workspaceStateDir,
} from './lib/paths.js'
import {
explainFailure,
findSlopo,
installEnv,
lastLogError,
commandLine,
runShell,
slopoArgs,
uvInstallArgs,
} from './lib/slopo.js'
/** Rank of a packaged bundle's skills. Hardcoded, not imported. */
const BUNDLED_SKILL_RANK = 600
/** Reserved provider name inside dsh; registering it throws. */
const PROVIDER_NAME = 'dsh-slopo'
/** Settings namespace. Must match /^[a-z][a-z0-9-]*$/. */
const SETTINGS_NAMESPACE = 'dsh-slopo'
const SKILL_NAMES = ['slopo-review', 'slopo-analyze-ignore', 'slopo-analyze-one']
const SKILLS_DIR = fileURLToPath(new URL('./skills/', import.meta.url))
/** Long-running actions, in the order the tool description advertises them. */
/**
* Actions that default to a background job.
*
* Only the warm-up. `index` plus `embed` is the long, watchable step — the one
* the user asked to be able to watch — while a review finds a warm index and
* returns its report directly. Named after dsh-tool-bash's `run_in_background`,
* because a model's prior is that backgrounding is something it ASKS for rather
* than the default.
*/
const BACKGROUND_BY_DEFAULT = new Set(['prepare'])
/** Every configuration field this plugin exposes. */
const SETTING_KEYS = [
'binary',
'autoInstall',
'embeddingModel',
'embeddingDimensions',
'embeddingApiBase',
'embeddingApiKey',
'embeddingParamsJson',
'sourceDir',
'sourceDirExclude',
'useDefaultExcludes',
'maxIndexUnits',
'ignoreFile',
'stateDir',
'embeddingRequestDelay',
'timeoutMs',
]
/**
* The plugin's composition schema.
*
* Nothing meaningful is defaulted: slopo itself requires `source_dir`,
* `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('').volatile(),
/** Install slopo into $DSH_HOME with uv when it is missing. */
autoInstall: Schema.boolean().default(true).volatile(),
/** LiteLLM model string, e.g. `ollama/jina-embeddings-v2-base-code:latest`. */
embeddingModel: Schema.string().default('').volatile(),
/** Output dimensions, which must match the model. */
embeddingDimensions: Schema.number().default(0).volatile(),
/** Provider base URL, e.g. `http://127.0.0.1:11434` for Ollama. */
embeddingApiBase: Schema.string().default('').volatile(),
/** Provider key. Passed to slopo in its environment, never written to disk. */
embeddingApiKey: Schema.string().default('').role('secret').volatile(),
/** Extra LiteLLM embedding params, as a JSON object string. */
embeddingParamsJson: Schema.string().default('{}').volatile(),
/** Code to index. Empty means the session's working directory. */
sourceDir: Schema.string().default('').volatile(),
/** Extra gitignore-style paths to skip, on top of the defaults. */
sourceDirExclude: Schema.array(String).default([]).volatile(),
/** Exclude vendored dependencies and build output. Off means slopo sees everything. */
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).volatile(),
/** Ignore file. Empty means inside the per-workspace state directory. */
ignoreFile: Schema.string().default('').volatile(),
/** State directory. Empty means $DSH_HOME/dsh-slopo/workspaces/<workspace>. */
stateDir: Schema.string().default('').volatile(),
/** Milliseconds between embedding requests, for rate-limited providers. */
embeddingRequestDelay: Schema.number().default(0).volatile(),
/** Cooperative budget for a foreground slopo run. */
timeoutMs: Schema.number().default(600_000).volatile(),
})
/** Resolve the harness home, preferring the harness's own resolver. */
function dshHomeOf(ctx) {
try {
const homePath = ctx.get('dshHomePath')
if (typeof homePath === 'function') {
const resolved = homePath()
if (typeof resolved === 'string' && resolved !== '') return resolved
}
} catch {
// The service is declared optional; absence is expected outside dsh-app-boot.
}
return resolveDshHome()
}
/** Every path one workspace's slopo state occupies. */
export function workspacePaths(dshHome, cwd, settings) {
const stateDir =
typeof settings.stateDir === 'string' && settings.stateDir.trim() !== ''
? settings.stateDir.trim()
: workspaceStateDir(dshHome, cwd)
return {
stateDir,
cwd,
sourceDir: typeof settings.sourceDir === 'string' && settings.sourceDir.trim() !== ''
? settings.sourceDir.trim()
: cwd,
configPath: join(stateDir, 'slopo.conf.yaml'),
dbFile: join(stateDir, 'slopo.db'),
reportDir: join(stateDir, 'report'),
ignoreFile:
typeof settings.ignoreFile === 'string' && settings.ignoreFile.trim() !== ''
? settings.ignoreFile.trim()
: join(stateDir, 'slopo.ignore.txt'),
logFile: join(stateDir, 'slopo.agent.log'),
}
}
/** Parse the JSON params field, tolerating an unset or malformed value. */
function embeddingParams(settings, problems) {
const raw = typeof settings.embeddingParamsJson === 'string' ? settings.embeddingParamsJson.trim() : ''
const params = {}
if (raw !== '' && raw !== '{}') {
try {
const parsed = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
problems.push('embeddingParamsJson must be a JSON object')
} else {
Object.assign(params, parsed)
}
} catch (error) {
problems.push(`embeddingParamsJson is not valid JSON: ${error.message}`)
}
}
const apiBase = typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase.trim() : ''
if (apiBase !== '') params.api_base = apiBase
return params
}
/**
* Map this plugin's settings onto slopo's own key names.
*
* 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.
*/
export function toSlopoConfig(settings, paths, problems = []) {
const config = {
source_dir: paths.sourceDir,
source_dir_exclude: effectiveExcludes(settings),
db_file: paths.dbFile,
report_dir: paths.reportDir,
ignore_file: paths.ignoreFile,
agent_log_file: paths.logFile,
embedding_model: typeof settings.embeddingModel === 'string' ? settings.embeddingModel.trim() : '',
// Omitted rather than written as 0 when unset: slopo requires a positive
// integer, and pruneConfig drops an undefined value entirely.
embedding_dimensions: Number(settings.embeddingDimensions) > 0 ? Number(settings.embeddingDimensions) : undefined,
embedding_params: embeddingParams(settings, problems),
embedding_request_delay: Number(settings.embeddingRequestDelay) || 0,
}
const { config: normalized, problems: numericProblems } = normalizeConfig(config)
problems.push(...numericProblems)
delete normalized.embedding_api_key
return normalized
}
/** Read the `description:` line out of a SKILL.md frontmatter block. */
export function readDescription(body) {
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body)
if (frontmatter === null) return undefined
const line = /^description:[ \t]*(.+)$/m.exec(frontmatter[1])
if (line === null) return undefined
const raw = line[1].trim()
if (raw.startsWith('"') && raw.endsWith('"') && raw.length > 1) {
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
}
if (raw.startsWith("'") && raw.endsWith("'") && raw.length > 1) return raw.slice(1, -1).replace(/''/g, "'")
return raw
}
const SKILL_INVOCATION = Object.freeze({ modelInvocable: false, userInvocable: true })
const RESOURCE_BASE = Object.freeze({ kind: 'directory', path: SKILLS_DIR })
/** Load one packaged skill, or `undefined` when it cannot be read. */
async function loadSkill(name, signal) {
const url = new URL(`${name}/SKILL.md`, new URL('./skills/', import.meta.url))
try {
const content = await readFile(url, { encoding: 'utf8', signal })
const description = readDescription(content)
if (description === undefined) return undefined
return { description, content, url }
} catch (error) {
if (error?.name === 'AbortError') throw error
return undefined
}
}
const skillsProvider = {
name: PROVIDER_NAME,
async list(options = {}) {
const found = []
for (const name of SKILL_NAMES) {
const skill = await loadSkill(name, options.signal)
if (skill === undefined) continue
found.push({
name,
description: skill.description,
invocation: SKILL_INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
rank: BUNDLED_SKILL_RANK,
locator: name,
})
}
return found
},
async get(candidate, options = {}) {
const name = typeof candidate.locator === 'string' ? candidate.locator : candidate.name
const skill = await loadSkill(name, options.signal)
if (skill === undefined) return undefined
return {
name,
description: skill.description,
invocation: SKILL_INVOCATION,
provider: PROVIDER_NAME,
source: 'bundled',
resourceBase: RESOURCE_BASE,
content: skill.content,
}
},
}
const BACKGROUND_OUTPUT = {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'background' },
action: { type: 'string', required: true },
jobId: { type: 'string', required: true },
logPath: { type: 'string' },
configPath: { type: 'string' },
},
}
const FOREGROUND_OUTPUT = {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'foreground' },
action: { type: 'string', required: true },
ok: { type: 'boolean', required: true },
exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] },
report: { type: 'string' },
message: { type: 'string' },
errorCode: { type: 'string' },
remedy: { type: 'string' },
logError: { type: 'string' },
configPath: { type: 'string' },
logPath: { type: 'string' },
details: { type: 'json' },
},
}
export const name = 'dsh-slopo'
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
*/
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.
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.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)
}
})
ctx.skills.registerProvider(() => skillsProvider)
/**
* 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)
if (found !== undefined) return found
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,
workdir: installInto,
})
if (install.exitCode !== 0) {
return {
error: 'SLOPO_INSTALL_FAILED',
remedy:
'Install slopo manually with `uv tool install slopo --python 3.12`, or set the `binary` setting to ' +
'an existing slopo executable.',
detail: install.stderr || install.stdout,
}
}
const installed = findSlopo(dshHome)
if (installed === undefined) {
return { error: 'SLOPO_INSTALL_MISSING', remedy: 'uv reported success but no executable appeared.' }
}
return installed
}
ctx.tools.register(
defineTool({
name: 'slopo',
description:
'Run slopo, an embedding-based duplicate-code detector, against the current workspace. ' +
'Actions: "status" reports the resolved configuration and index state; "configure" updates the ' +
'dsh-slopo settings; "prepare" runs the local index and then embeds (the slow, possibly costly ' +
'step); "review" finds similar code involving Git changes; "analyze" triages the whole codebase; ' +
'"analyze-one" reports a single cluster by hash; "update" upgrades the slopo CLI. Slopo is ' +
'installed and driven as a separate process. "prepare" starts a background job by default, because ' +
'indexing and embedding a repository takes minutes and slopo writes its progress to a log file rather ' +
'than to stdout; everything else returns its report directly.',
parameters: {
action: {
type: 'string',
required: true,
enum: ['status', 'configure', 'prepare', 'review', 'analyze', 'analyze-one', 'update'],
description: 'What to do.',
},
base: { type: 'string', description: 'Git ref to compare against for "review". Defaults to HEAD.' },
cluster: { type: 'string', description: 'Cluster hash for "analyze-one". Omit for the next cluster.' },
run_in_background: {
type: 'boolean',
description:
'Return a job id instead of the result, for a run long enough to watch. Defaults to true for ' +
'"prepare" (the warm-up) and false for everything else, which is quick once the index is warm.',
},
config: {
type: 'json',
description:
'Settings patch for "configure", using this plugin\'s own camelCase field names, for example ' +
'{"embeddingModel":"ollama/nomic-embed-text:latest","embeddingDimensions":768}.',
},
},
output: {
schema: { oneOf: [BACKGROUND_OUTPUT, FOREGROUND_OUTPUT] },
render: (_args, value) => {
if (value.kind === 'background') {
const log = value.logPath === undefined ? '' : ` Progress is written to ${value.logPath}.`
return [
{
type: 'text',
text:
`Started slopo ${value.action} as background job ${value.jobId}.${log} You will be notified when ` +
`it settles; collect it with job_output for "${value.jobId}" (add wait: true if you are blocked on it).`,
},
]
}
const parts = []
if (typeof value.report === 'string' && value.report !== '') parts.push(value.report)
if (typeof value.message === 'string' && value.message !== '') parts.push(value.message)
if (typeof value.logError === 'string' && value.logError !== '') parts.push(`Slopo log: ${value.logError}`)
if (typeof value.remedy === 'string' && value.remedy !== '') parts.push(`Next: ${value.remedy}`)
if (typeof value.logPath === 'string' && value.logPath !== '') parts.push(`(agent log: ${value.logPath})`)
if (parts.length === 0) parts.push(`slopo ${value.action} finished with exit code ${value.exitCode}.`)
return [{ type: 'text', text: parts.join('\n\n') }]
},
},
async execute(args, exec) {
const cwd = exec.agent?.session.header.cwd ?? process.cwd()
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
// resolution. The NO_SANDBOX_POLICY refusal stays with the actions that
// actually execute something.
const sandboxDefault = shell?.sandboxMode
const policyService = sandboxDefault === undefined ? undefined : ctx.get('sandboxPolicy')
const sandboxPolicy = policyService?.resolve(
exec.agent === undefined ? {} : { session: exec.agent.session },
)
// Every return funnels through here. The harness enforces the declared
// output schema against this value BEFORE materialising it, and an
// explicit `undefined` property is not lossless JSON — which is exactly
// how a probe result carrying `error: undefined` broke the status call.
const foreground = (value) =>
toJsonValue({
kind: 'foreground',
action: args.action,
configPath: paths.configPath,
logPath: paths.logFile,
...value,
})
if (args.action === 'status') {
const binary = findSlopo(dshHome)
const problems = []
const slopoConfig = toSlopoConfig(settings, paths, problems)
const missing = missingRequired(slopoConfig)
// Two pre-flight checks, because slopo reports a bad embedding
// configuration as the two words "Embedding failed." on stdout with
// the real reason buried in its agent log. That is a slow way to
// discover a wrong provider prefix or a dimension mismatch.
let slopoVersion
if (binary !== undefined && shell !== undefined) {
try {
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)}`
} catch (error) {
slopoVersion = `could not run: ${error?.message ?? error}`
}
}
// A direct fetch rather than the shell seam: the probe is one small
// HTTP request and does not belong behind a sandbox boundary that
// exists to confine filesystem writes.
const endpoint =
missing.length === 0
? await probeEmbeddingEndpoint({
apiBase: typeof settings.embeddingApiBase === 'string' ? settings.embeddingApiBase : '',
model: String(slopoConfig.embedding_model ?? ''),
dimensions: Number(slopoConfig.embedding_dimensions ?? 0),
apiKey: typeof settings.embeddingApiKey === 'string' ? settings.embeddingApiKey : '',
})
: { checked: false, skipped: 'nothing is configured yet' }
let message
if (missing.length > 0) {
message =
`Not configured yet. Missing: ${missing.join(', ')}. Run this tool with action "configure" ` +
'to set embeddingModel and embeddingDimensions, then "prepare".'
} else if (endpoint.ok === false) {
message =
`Configured, but the embedding backend failed its check: ${endpoint.error}. ` +
`(probed ${endpoint.url} with model "${endpoint.sentModel}")`
} else if (endpoint.checked === true) {
message =
`Ready. ${binary ?? 'slopo'} will use "${slopoConfig.embedding_model}" at ` +
`${Number(slopoConfig.embedding_dimensions)} dimensions, confirmed against ${endpoint.url}.`
} else {
message =
`Configured against "${slopoConfig.embedding_model}" at ` +
`${Number(slopoConfig.embedding_dimensions)} dimensions. Nothing was probed: ${endpoint.skipped}.`
}
return foreground({
ok: missing.length === 0 && endpoint.ok !== false,
exitCode: null,
details: {
dshHome,
cwd,
stateDir: paths.stateDir,
configPath: paths.configPath,
database: existsSync(paths.dbFile) ? paths.dbFile : null,
ignoreFile: existsSync(paths.ignoreFile) ? paths.ignoreFile : null,
agentLog: existsSync(paths.logFile) ? paths.logFile : null,
slopoBinary: binary ?? null,
slopoVersion: slopoVersion ?? null,
slopoInstalledInto: binary === undefined ? null : dshHome,
settingsNamespace: writeSettings === undefined ? null : SETTINGS_NAMESPACE,
embeddingModel: slopoConfig.embedding_model ?? null,
embeddingDimensions: slopoConfig.embedding_dimensions ?? null,
apiKeyConfigured: typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== '',
embeddingEndpoint: endpoint,
// What slopo will actually look at, and the limit on how much of
// it may be embedded without being asked. Both are the levers a
// runaway scope is fixed with, so status has to name them.
sourceDirExclude: effectiveExcludes(settings),
useDefaultExcludes: settings.useDefaultExcludes !== false,
maxIndexUnits: Number(settings.maxIndexUnits),
missingSettings: missing,
problems,
},
message,
})
}
if (args.action === 'configure') {
const patch = args.config
if (patch === undefined || patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
return foreground({ ok: false, exitCode: null, message: 'The `config` parameter must be an object.', errorCode: 'BAD_PATCH' })
}
const unknown = Object.keys(patch).filter((key) => !SETTING_KEYS.includes(key))
if (unknown.length > 0) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'UNKNOWN_SETTING',
message: `Unknown setting(s): ${unknown.join(', ')}.`,
remedy: `Known settings: ${SETTING_KEYS.join(', ')}.`,
})
}
if (writeSettings === undefined) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NO_SETTINGS_PROVIDER',
message: 'No settings provider is mounted, so configuration cannot be persisted at runtime.',
remedy: `Set these values in the dsh-slopo row of your cordis layer instead: ${JSON.stringify(patch)}`,
})
}
await writeSettings(patch)
return foreground({ ok: true, exitCode: 0, message: `Updated ${Object.keys(patch).join(', ')}.` })
}
if (shell === undefined) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NO_SHELL',
message: 'No shell executor is available, so slopo cannot be run.',
remedy: 'Load @deepseek-ai/dsh-bash-local (or another shell provider) into this profile.',
})
}
// Resolve the CALLING SESSION's sandbox policy, the way the bash tool
// does. `dsh-bash-sandbox.resolve()` stamps
// `request.sandboxPolicy ?? ctx.sandboxPolicy.resolve()`, so omitting it
// does not mean "unconfined" — it silently substitutes the DEPLOYMENT
// default (`DSH_PERMISSION_MODE ?? 'workspace-write'`) for the session's
// preset. That confines a call the session asked to run unconfined, and
// on a host with no sandbox runner it fails with SandboxUnavailableError
// before slopo is ever reached.
if (sandboxDefault !== undefined && policyService === undefined) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NO_SANDBOX_POLICY',
message: 'The mounted shell executor confines commands, but ctx.sandboxPolicy is absent.',
remedy: 'Compose @deepseek-ai/dsh-sandbox-policy, or mount an unconfining shell executor.',
})
}
// Slopo's state is deliberately outside the workspace, which a
// workspace-write sandbox forbids writing to. Say so before a run and
// name both ways out, rather than letting a write fail mid-embed.
if (
sandboxPolicy !== undefined &&
sandboxPolicy.mode !== 'danger-full-access' &&
!isInside(sandboxPolicy.workspaceRoot, paths.stateDir)
) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'STATE_OUTSIDE_SANDBOX',
message:
`This session runs under the "${sandboxPolicy.mode}" sandbox, which permits writes only under ` +
`${sandboxPolicy.workspaceRoot} — but slopo's index, config and log live in ${paths.stateDir}.`,
remedy:
'Either run this session under the danger-full-access preset, or set `stateDir` to a directory ' +
'inside the workspace using action "configure".',
})
}
/**
* 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: result.stdout.truncated || result.stderr.truncated,
timedOut: Boolean(result.timedOut),
}
}
if (args.action === 'update') {
const result = await run(commandLine(['uv', ...uvInstallArgs({ upgrade: true })]), {
env: installEnv(dshHome),
timeoutMs: 900_000,
signal: exec.signal,
})
return foreground({
ok: result.exitCode === 0,
exitCode: result.exitCode,
report: result.stdout.trim(),
errorCode: result.exitCode === 0 ? undefined : 'UPDATE_FAILED',
remedy: result.exitCode === 0 ? undefined : result.stderr.trim(),
})
}
// Everything below actually needs slopo.
const problems = []
const slopoConfig = toSlopoConfig(settings, paths, problems)
const missing = missingRequired(slopoConfig)
if (missing.length > 0) {
return foreground({
ok: false,
exitCode: null,
errorCode: 'NOT_CONFIGURED',
message: `Slopo is not configured for this workspace yet. Missing: ${missing.join(', ')}.`,
remedy:
'Call this tool with action "configure" first, for example ' +
'{"embeddingModel":"ollama/<model>:latest","embeddingDimensions":768,"embeddingApiBase":"http://127.0.0.1:11434"}.',
details: { missing, problems },
})
}
// 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 })
}
const env =
typeof settings.embeddingApiKey === 'string' && settings.embeddingApiKey !== ''
? { SLOPO_EMBEDDING_API_KEY: settings.embeddingApiKey }
: undefined
// Every action indexes FIRST, so the size gate sees the real scope
// before any embedding is paid for. Slopo's own >1000 guard cannot do
// this: it measures the pre-existing backlog, and a fresh database has
// no units at all — which is exactly when a runaway costs the most.
const excludes = effectiveExcludes(settings)
const indexArgs = slopoArgs(paths.configPath, 'index')
const restArgs =
args.action === 'prepare'
? [slopoArgs(paths.configPath, 'embed')]
: [slopoArgs(paths.configPath, args.action, { base: args.base, cluster: args.cluster })]
/**
* Index, check the budget, then run the action.
* Returns a refusal instead of running when the scope is too large.
*/
const runScoped = async (signal) => {
const indexed = await run(commandLine([binary, ...indexArgs]), { env, signal })
const parsed = parseIndexOutput(indexed.stdout)
// Prefer the database. `Indexed N` counts only what was NEWLY indexed,
// so it reads zero on a warm database whose backlog is still full —
// exactly the state a refused run leaves behind.
const fromDb = readIndexCounts(paths.dbFile)
const counted =
fromDb === undefined
? parsed
: { units: fromDb.unembedded, files: fromDb.files, inScope: fromDb.units, embedded: fromDb.embedded }
if (indexed.exitCode !== 0) return { refused: undefined, counted, outcome: indexed }
const decision = checkBudget(counted, Number(settings.maxIndexUnits), excludes)
if (!decision.allowed) return { refused: decision.reason, counted, outcome: undefined }
const restCommand = restArgs.map((argv) => commandLine([binary, ...argv])).join(' && ')
return { refused: undefined, counted, outcome: await run(restCommand, { env, signal }) }
}
const scopeRefusal = (reason, counted) =>
foreground({
ok: false,
exitCode: null,
errorCode: 'SCOPE_TOO_LARGE',
message: reason,
remedy: budgetRemedy(Number(settings.maxIndexUnits), excludes),
details: { counted, excludes, maxIndexUnits: Number(settings.maxIndexUnits) },
})
// Background is for the long warm-up, not for every action. A completion
// notice is best-effort: a KILLED job deliberately produces none, and an
// agent whose turn has ended may have the message injected rather than be
// woken for it. A review that returns its report directly is worth more
// than a job id the caller has to remember to collect.
const wantsBackground = args.run_in_background ?? BACKGROUND_BY_DEFAULT.has(args.action)
const jobs = ctx.get('jobs')
if (wantsBackground && jobs !== undefined) {
const jobId = jobs.start({
kind: 'slopo',
label: `slopo ${args.action}`,
// 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)
if (refused !== undefined) {
return {
status: 'failed',
detail: 'SCOPE_TOO_LARGE',
result: `${refused}\n\n${budgetRemedy(Number(settings.maxIndexUnits), excludes)}`,
}
}
if (outcome.exitCode === 0) {
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,
result: [explained.message, explained.remedy, logDetail.trim()].filter((line) => line !== '').join('\n\n'),
}
})()
return { cancel: () => controller.abort(), done }
},
})
return toJsonValue({ kind: 'background', action: args.action, jobId, logPath: paths.logFile, configPath: paths.configPath })
}
const { refused, counted, outcome } = await runScoped(exec.signal)
const logDetail = await readLogTail(paths.logFile)
if (refused !== undefined) return scopeRefusal(refused, counted)
if (outcome.exitCode === 0) {
return foreground({ ok: true, exitCode: 0, report: outcome.stdout.trim() || 'No duplicates found' })
}
const explained = explainFailure({ stderr: outcome.stderr, stdout: outcome.stdout, logDetail })
return foreground({
ok: false,
exitCode: outcome.exitCode,
errorCode: explained.code,
message: explained.message,
remedy: explained.remedy,
logError: lastLogError(logDetail),
})
},
}),
)
}
/** Read the tail of slopo's agent log, tolerating a missing file. */
async function readLogTail(path) {
try {
const contents = await readFile(path, 'utf8')
return contents.length > 16_000 ? contents.slice(-16_000) : contents
} catch {
return ''
}
}
/** True when `path` is `root` itself or sits underneath it. */
function isInside(root, path) {
const prefix = root.endsWith(sep) ? root : root + sep
return path === root || path.startsWith(prefix)
}