diff --git a/README.md b/README.md
index ae20514..262ceda 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,9 @@ are in without remembering flags.
| `/slopo-analyze-ignore` | Triages the whole codebase's clusters, writing the obvious false positives into the ignore file. |
| `/slopo-analyze-one` | Reports one cluster at a time — optionally by hash — and judges whether it is worth acting on. |
| the `slopo` tool | Resolves the CLI, materialises the config, runs the work, and turns slopo's terse failures into something actionable. |
-| the `dsh-slopo` settings namespace | The embedding backend and per-workspace state, registered on the Host. |
-| the settings card | A form for all of it, in Settings → Plugins → Plugin configuration. |
+| the `dsh-slopo` configuration | The embedding backend and per-workspace state. It is the Host plugin's own `Config`, so the deployment's schema and the form can never disagree. |
+| the settings card | A form for all of it, on the **Plugins** page under the `dsh-slopo` bundle. |
+
All three skills are **user-invocable and deliberately not model-invocable**,
matching slopo's own agent configs. Typing `/slopo-review` anywhere in a message
injects that skill; the model will not reach for one on its own. Every one of
@@ -41,23 +42,31 @@ allowlist a build before the first install can succeed.
## Settings
-The plugin contributes a card to **Settings → Plugins → Plugin configuration**,
-keyed on the `dsh-slopo` settings namespace the Host registers. Every setting in
-the table below is editable there, overridden fields are marked, and Reset
-returns a field to the deployment default rather than to empty.
+The plugin ships a configuration page on the **Plugins** page — the sidebar
+entry that lists bundles, rows and official plugins — reached by opening the
+`dsh-slopo` bundle and pressing **Configure**. Its key is the bundle's package
+name, which is also the namespace: the Loader entry id the bundle's patch
+declares.
-The card behaves the way the other plugin cards do: it starts collapsed, its
-header is a button that discloses the controls in place, the header marks it when
-it holds unsaved edits — visible even while collapsed, since staged edits outlive
-being folded away — and a save that lands collapses it again.
+That the namespace is the entry id is the point. Since dsh 0.1.7-rc.2 the
+framework projects this plugin's exported `Config` schema onto the entry that
+composed it, so there is one source of truth for what is configurable. Every
+field is marked `volatile()`, which is what puts it on the page, and
+`embeddingApiKey` additionally carries `role('secret')`, so the browser is told
+whether a key is stored and never receives the key itself.
+
+Every setting in the table below is editable there; overridden fields are
+marked, a draft that is not a valid value blocks the save, and Reset returns a
+field to the deployment default rather than to empty. A save is one
+revision-fenced write, so a second editor cannot be silently overwritten.
The card is the bundle's second half — the browser half. It needs no build step
and ships as plain ESM in the module-loader wrapper, but the platform snapshots
client bundles at activation, so **a client half only appears after the profile
-restarts.** Without it the namespace still works — it is editable in
-`$DSH_HOME/settings.yaml` and through the tool's `configure` action — there is
-simply no form drawn for it, because the tab renders the intersection of served
-namespaces and registered cards.
+restarts.** Without it the namespace still works — it is editable in the
+profile's patch layer and through the tool's `configure` action — there is
+simply no form drawn for it, because the page only renders a bundle that
+registers a configuration entry for itself.
## Configure it
@@ -84,7 +93,9 @@ Then set it up:
}
```
-Values land in the `dsh-slopo` section of `$DSH_HOME/settings.yaml`.
+Values land in the `config` of the `dsh-slopo` row in the profile's patch layer
+(and, from the browser, in the same row through the settings domain — the card
+and the tool write the same place).
| Setting | Meaning |
| --- | --- |
@@ -228,6 +239,29 @@ Every action indexes first so the gate runs before anything expensive, and
## Things worth knowing
+**The harness seam moved under this plugin once, and the failure was silent.**
+dsh 0.1.7-rc.2 replaced three things this bundle used:
+
+| was | is |
+| --- | --- |
+| `ctx.settings.installSection(ctx, ns, Config, config, …)` | the exported `Config` itself, projected onto the Loader entry; `ctx.settings.configure({ auto: false })` claims the entry's own page |
+| a plugin-chosen settings namespace + a `settings.plugin.item` card | the entry id, plus a `plugins.bundle.config` card keyed by the bundle's package name |
+| `ctx.shell.run(spec)` | `await ctx.shell.execute(ctx.shell.resolve(request)).result()` |
+
+All three broke quietly rather than loudly. `installSection` was missing, so the
+call threw into a `try/catch` that logged a warning nobody reads; the card's slot
+and the `settingsScope` service it asked for no longer existed, so the card
+simply never rendered; and `shell.run` threw only on the paths that actually run
+a command. On top of that, a `Config` field that is not marked `volatile()` is
+invisible to every settings UI, and the `jobs` registry wants `exec.agent.id`
+(a session id) as its owner — handing it the agent object registered a job no
+session could collect.
+
+Two habits follow. First, this bundle's tests bind to the *installed* harness's
+own slot catalog and package exports, so a rename fails a test instead of
+quietly emptying a page. Second, the browser half keeps its slot and service
+names in exactly one place each, so the next rename is a one-line edit.
+
**Five settings are frozen at first index.** `source_dir`, `embedding_model`,
`embedding_dimensions` and both node-count thresholds are written into
`slopo.db` on the first `index`, and every later run refuses to mix generations:
@@ -296,18 +330,38 @@ that silently does nothing. Two real bugs were caught this way: a config
renderer that would write the API key to disk if handed one, and `--config-version`
placed before the subcommand, which slopo rejects with a usage error.
+The mock's seams are shaped like the harness's, deliberately, because that is
+what makes this file a contract test. `ctx.shell` exposes `resolve`/`execute`
+with a handle whose `result()` settles the run — there is no `run(spec)`, which
+is exactly the method dsh 0.1.7-rc.2 removed. `ctx.settings` has no
+`installSection` either; it has `configure` and `update`, and the tests assert
+that the plugin claims its own page and writes to the entry id the bundle patch
+declares. The `jobs` fake records the spec, so the test can check that a
+background job is owned by a **session id** and that its `done` resolves a
+`JobOutcome` carrying `result`.
+
The browser half has no browser to run in, so its tests evaluate the bundle the
way the real module loader does — inside `window.__ModuleLoader__.load` — with a
-fake `require` that answers `react` and throws on anything else. That last part is
-deliberate: it checks the card stays inside the platform's seed table, which is
-the constraint a hand-written client half is most likely to break. The card is
-then rendered and its handlers driven, which is not a substitute for looking at
-it but does cover the wiring it cannot work without.
+fake `require` that answers `react` and `@deepseek-ai/dsh-client-ui-primitives`
+and throws on anything else. That last part is deliberate: it checks the card
+stays inside the platform's seed table, which is the constraint a hand-written
+client half is most likely to break. The card is then rendered and its handlers
+driven — including the field specs, run through a copy of the shipped
+`SettingsFormModel` staging semantics — which is not a substitute for looking at
+it but does cover the wiring it cannot work without. Three further tests read the
+*installed* harness instead: that `plugins.bundle.config` is a declared slot and
+`settings.plugin.item` is gone, that the slot is dispatched by bundle package
+name, and that every primitive the card requires is still exported. They skip
+when no global dsh installation is present.
`node_modules/@deepseek-ai` is a local symlink to the installed harness so the
`@deepseek-ai/*` imports resolve during tests. It is gitignored and is not a
-declared dependency — an out-of-tree bundle that depended on an in-box package
-would install a second copy that drifts from the host's.
+declared dependency — `peerDependencies` states the compatibility contract
+instead, and an out-of-tree bundle that depended on an in-box package would
+install a second copy that drifts from the host's. The declared peers are what
+the harness's compatibility gate reads: a plugin whose `@deepseek-ai/dsh-*`
+ranges do not admit the running version is refused at startup rather than
+half-loaded.
## Releases and updating
@@ -321,14 +375,20 @@ would install a second copy that drifts from the host's.
`vX.Y.Z`;
4. creates the Gitea release with those notes as the body.
-The bump is a patch unless a commit in the release says otherwise:
+The bump is read from the commit subjects in the release, so the intent travels
+with the change instead of living in a file someone has to remember to update:
| in a commit subject | effect |
|---|---|
-| `[minor]` | release as a minor version |
-| `[major]` | release as a major version |
+| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
+| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
+| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
+| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
| `[skip release]` | this push publishes nothing |
+Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
+flag leak` is a patch, not a minor.
+
With no tags yet, the version already in `package.json` is what gets tagged — the
first release should match what the code says, not invent a number.
@@ -338,8 +398,16 @@ commits carry one; otherwise the notes are a flat list. Prose commit subjects ge
no invented headings.
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner, and
-`npm run test:release` exercises it — 31 checks, including an empty range, a
-repository with no tags yet, and a patch number already at 9.
+`npm run test:release` exercises it — an empty range, a repository with no tags
+yet, a patch number already at 9, a `feat` that is only a word. `npm run
+release:plan` says whether the current HEAD would release, and `npm run
+release:notes` prints the notes it would publish, both without releasing.
+
+The runner needs nothing but Gitea's own job token (`secrets.GITEA_TOKEN`), which
+`permissions: contents: write` in the workflow is what unlocks. Two caveats worth
+knowing before the first push: Actions has to be enabled on the repository, and a
+runner with the `ubuntu-latest` label has to be registered against the instance —
+otherwise the run sits queued forever.
### Making `dsh plugin update` work
diff --git a/client/client.js b/client/client.js
index b223323..0e56b8b 100644
--- a/client/client.js
+++ b/client/client.js
@@ -1,26 +1,37 @@
/**
- * dsh-slopo — browser half: the card in Settings → Plugins → Plugin configuration.
+ * dsh-slopo — browser half: the bundle's configuration page on the Plugins page.
*
- * The Host half registers a `dsh-slopo` settings namespace. This file registers
- * a card under that exact namespace key. Both are required: the tab renders the
- * INTERSECTION of the namespaces the Host serves and the cards the browser
- * claims, so a served namespace with no card draws nothing at all, and a card
- * whose namespace is not served is never dispatched.
+ * Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
+ * were: a settings namespace a plugin registered for itself, plus a
+ * `settings.plugin.item` card the plugin's client half registered under that
+ * namespace key, rendered inside Settings → Plugins → Plugin configuration.
+ * 0.1.7-rc.2 removed both halves of that contract:
*
- * The chrome follows the framework's `PluginCard`: the root is an `
` (the
- * tab renders every card into a ``), the header is its own `` that
- * discloses the controls in place, the card starts collapsed, it marks itself
- * when it holds unsaved edits, and it collapses again after a successful save.
- * Nothing about that is imported — `PluginCard` lives in that package's client
- * bundle and the client purity gate forbids cross-plugin value imports — so the
- * behaviour is reproduced here rather than borrowed.
+ * * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
+ * longer chosen by the plugin — it IS the Loader profile entry id, which the
+ * bundle's own patch declares (`dsh-slopo`).
+ * * the `settings.plugin.item` slot and the `settingsScope` service no longer
+ * exist either. Configuration now lives on the Plugins page, and a bundle's
+ * own settings register into `plugins.bundle.config` keyed by the bundle's
+ * PACKAGE NAME, which the page only dispatches for a package it lists.
+ *
+ * The service that replaced `settingsScope` is `configForms`, and the object it
+ * hands out for one entry (`configForms.get(id)`) is API-compatible with what
+ * this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`.
+ * What actually changed is the chrome. The framework now ships the form
+ * vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and
+ * fences the save with the revision the drafts were read at, `SettingsForm`
+ * draws the frame and the single Save, and the field controls carry the
+ * overridden badge, the reset and the invalid-edit block. Reproducing that by
+ * hand is what the file used to do; using it is the point.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
* output, so a `prepare` script would make every user allowlist a build before
- * the first install succeeds. The only import is `react`, which is in the
- * platform seed table; anything outside that table would have to be bundled
- * into this file or declared in `dsh.client.external`.
+ * the first install succeeds. The imports are `react` and
+ * `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
+ * outside that table would have to be bundled into this file or declared in
+ * `dsh.client.external`.
*
* Styling is inline on purpose, for the same reason: there is no stylesheet this
* file may reach.
@@ -32,338 +43,403 @@ window.__ModuleLoader__.load({
var exports = module.exports
const react = require('react')
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
- const NS = 'dsh-slopo'
const h = react.createElement
-
- const TITLE = 'Slopo'
- const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
+ const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
+ const { settingsNumberField, settingsTextField } = primitives
/**
- * The settings this card edits.
+ * The bundle's package name.
*
- * This list mirrors the Host schema in index.js. It is duplicated rather
- * than fetched because the card renders its own controls — there is no
- * generic schema-to-form renderer in the framework — and a field the Host
- * does not know is rejected there anyway, so a drift shows up as a clear
- * error rather than silent data loss.
+ * One string plays three roles and they must agree: it is the npm package
+ * the Plugins page lists, the Loader entry id inside that bundle's patch
+ * (and therefore the settings namespace `configForms` addresses), and the
+ * `plugins.bundle.config` key this card registers under.
+ */
+ const BUNDLE = 'dsh-slopo'
+
+ /** Dictionary namespace owned by this plugin. */
+ const NS = 'dsh-slopo'
+
+ /** The one write-only field: a credential the wire never carries back. */
+ const API_KEY_FIELD = 'embeddingApiKey'
+
+ const DESCRIPTION =
+ 'Embedding backend, index scope and the budget that stops a large embed.'
+
+ /** The card's own copy. */
+ const en = {
+ description: DESCRIPTION,
+ overridden: 'Overridden',
+ reset: 'Reset to default',
+ readOnly: 'This deployment stores settings read-only.',
+ unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
+ save: 'Save',
+ saving: 'Saving…',
+ saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
+ invalidNumber: 'Enter a number, or leave blank to use the default.',
+ apiKeySet: 'A key is stored.',
+ apiKeyUnset: 'No key is stored.',
+ listHint: 'Comma separated.',
+ 'binary.label': 'slopo executable',
+ 'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.',
+ 'autoInstall.label': 'Install slopo with uv when missing',
+ 'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.',
+ 'embeddingModel.label': 'Embedding model',
+ 'embeddingModel.hint':
+ 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
+ 'embeddingDimensions.label': 'Embedding dimensions',
+ 'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.',
+ 'embeddingApiBase.label': 'Embedding API base',
+ 'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434',
+ 'embeddingApiKey.label': 'Embedding API key',
+ 'embeddingApiKey.hint':
+ 'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.',
+ 'embeddingParamsJson.label': 'Extra embedding parameters',
+ 'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.',
+ 'sourceDir.label': 'Source directory',
+ 'sourceDir.hint': "Empty uses the session's working directory.",
+ 'sourceDirExclude.label': 'Extra exclude patterns',
+ 'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.',
+ 'useDefaultExcludes.label': 'Exclude node_modules and build output',
+ 'useDefaultExcludes.hint':
+ 'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
+ 'maxIndexUnits.label': 'Refuse to embed more than this many units',
+ 'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.',
+ 'ignoreFile.label': 'Ignore file',
+ 'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.',
+ 'stateDir.label': 'State directory',
+ 'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/.',
+ 'embeddingRequestDelay.label': 'Delay between embedding requests (ms)',
+ 'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.',
+ 'timeoutMs.label': 'Foreground timeout (ms)',
+ 'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.',
+ }
+
+ /**
+ * The fields this card edits, in display order.
+ *
+ * This list mirrors the Host schema in index.js, including `kind`. It is
+ * duplicated rather than fetched because the card renders its own controls —
+ * there is no schema-to-form renderer in the framework — and a field the
+ * Host does not know is rejected there anyway, so a drift shows up as a
+ * clear error rather than silent data loss. Every key must be a `volatile()`
+ * field of that schema or the write is refused.
*/
const FIELDS = [
- {
- key: 'embeddingModel',
- label: 'Embedding model',
- hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
- },
- { key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' },
- { key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' },
- {
- key: 'embeddingApiKey',
- label: 'Embedding API key',
- kind: 'password',
- hint: 'Passed to slopo in its environment and never written into the slopo config file.',
- },
- { key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' },
- { key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' },
- { key: 'autoInstall', label: 'Install slopo with uv when missing', kind: 'boolean' },
- { key: 'sourceDir', label: 'Source directory', hint: 'Empty uses the session working directory.' },
- {
- key: 'sourceDirExclude',
- label: 'Extra exclude patterns',
- kind: 'list',
- hint: 'Comma separated, gitignore style. Added to the defaults rather than replacing them.',
- },
- {
- key: 'useDefaultExcludes',
- label: 'Exclude node_modules and build output',
- kind: 'boolean',
- hint: 'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
- },
- {
- key: 'maxIndexUnits',
- label: 'Refuse to embed more than this many units',
- kind: 'number',
- hint: 'Makes a large scope a question rather than an hours-long embed. 0 removes the limit.',
- },
- { key: 'stateDir', label: 'State directory', hint: 'Empty uses $DSH_HOME/dsh-slopo/workspaces/.' },
- { key: 'ignoreFile', label: 'Ignore file', hint: 'Point this at the repository to version the dismissed clusters.' },
- { key: 'embeddingRequestDelay', label: 'Delay between embedding requests (ms)', kind: 'number' },
- { key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
+ { key: 'embeddingModel', kind: 'text' },
+ { key: 'embeddingDimensions', kind: 'number' },
+ { key: 'embeddingApiBase', kind: 'text' },
+ { key: 'embeddingApiKey', kind: 'secret' },
+ { key: 'embeddingParamsJson', kind: 'text' },
+ { key: 'binary', kind: 'text' },
+ { key: 'autoInstall', kind: 'boolean' },
+ { key: 'sourceDir', kind: 'text' },
+ { key: 'sourceDirExclude', kind: 'list' },
+ { key: 'useDefaultExcludes', kind: 'boolean' },
+ { key: 'maxIndexUnits', kind: 'number' },
+ { key: 'ignoreFile', kind: 'text' },
+ { key: 'stateDir', kind: 'text' },
+ { key: 'embeddingRequestDelay', kind: 'number' },
+ { key: 'timeoutMs', kind: 'number' },
]
- const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
+ /** A label or hint from this card's dictionary. */
+ const copy = (t, key, part) => t(`${key}.${part}`)
- const S = {
- // The tab renders into a , so the root must be an or the list
- // styling never reaches it.
- card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
- cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
- header: {
- display: 'flex',
- alignItems: 'center',
- gap: '12px',
- width: '100%',
- padding: '12px 16px',
- border: 'none',
- background: 'transparent',
- color: 'inherit',
- textAlign: 'left',
- cursor: 'pointer',
- font: 'inherit',
+ /** Every field's label and hint, resolving against the active locale. */
+ const describeField = (t, field) => ({
+ key: field.key,
+ kind: field.kind,
+ label: copy(t, field.key, 'label'),
+ hint: copy(t, field.key, 'hint'),
+ id: `plugin-config-${BUNDLE}-${field.key}`,
+ })
+
+ /**
+ * A boolean field's draft conversion.
+ *
+ * The shared kit exports number and text specs only; a switch is a set of
+ * `true`/`false`, so the control has nothing to parse but the two literals
+ * it renders. Anything else is an invalid draft, which blocks the save
+ * rather than silently writing `false`.
+ */
+ const settingsBooleanField = (field) => ({
+ field,
+ format: (value) => (value === true ? 'true' : 'false'),
+ parse: (text) => {
+ const trimmed = text.trim()
+ if (trimmed === 'true') return { kind: 'set', value: true }
+ if (trimmed === 'false') return { kind: 'set', value: false }
+ return undefined
},
- headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
- name: { fontSize: '14px', fontWeight: 600 },
- description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
- pending: {
- flex: '0 0 auto',
- fontSize: '10px',
- textTransform: 'uppercase',
- letterSpacing: '0.04em',
- padding: '2px 6px',
- borderRadius: '4px',
- border: BORDER,
- opacity: 0.8,
- },
- chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
- chevronOpen: { transform: 'rotate(180deg)' },
- body: { padding: '0 16px 16px 16px' },
- readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
- hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
- row: { marginTop: '12px' },
- label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
- input: {
- width: '100%',
- boxSizing: 'border-box',
- padding: '6px 8px',
- fontSize: '12px',
- borderRadius: '6px',
- border: BORDER,
- background: 'transparent',
- color: 'inherit',
- },
- actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
- button: {
- padding: '6px 12px',
- fontSize: '12px',
- borderRadius: '6px',
- border: BORDER,
- background: 'transparent',
- color: 'inherit',
- cursor: 'pointer',
- },
- primary: { fontWeight: 600 },
- note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
+ })
+
+ /**
+ * A string-array field's draft conversion: a comma-separated list.
+ *
+ * An empty draft is an empty list rather than a clear: the schema declares
+ * this field's default as `[]`, so "no extra patterns" and "fall back to
+ * the composition layer" are the same answer.
+ */
+ const settingsListField = (field) => ({
+ field,
+ format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
+ parse: (text) => ({
+ kind: 'set',
+ value: text
+ .split(/[,\n]/)
+ .map((part) => part.trim())
+ .filter((part) => part !== ''),
+ }),
+ })
+
+ /** The spec one field's drafts are converted with. */
+ function specFor(field) {
+ if (field.kind === 'number') return settingsNumberField(field.key)
+ if (field.kind === 'boolean') return settingsBooleanField(field.key)
+ if (field.kind === 'list') return settingsListField(field.key)
+ return settingsTextField(field.key)
}
- /** The editable string form of one field's value. */
- function toText(field, value) {
- if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
- if (field.kind === 'boolean') return value === true ? 'true' : 'false'
- if (value === undefined || value === null) return ''
- return String(value)
- }
+ /** The form frame's copy, read from this card's dictionary. */
+ const formLabels = (t) => ({
+ unavailable: t('unavailable'),
+ readOnly: t('readOnly'),
+ saveFailed: t('saveFailed'),
+ save: t('save'),
+ saving: t('saving'),
+ })
- /** The value to write for one field, from its string form. */
- function fromText(field, text) {
- const trimmed = typeof text === 'string' ? text.trim() : ''
- if (field.kind === 'number') return Number(trimmed)
- if (field.kind === 'boolean') return trimmed === 'true'
- if (field.kind === 'list') {
- return trimmed === ''
- ? []
- : trimmed
- .split(/[,\n]/)
- .map((part) => part.trim())
- .filter((part) => part !== '')
- }
- return text
- }
+ /** The field-control copy shared by every row. */
+ const fieldLabels = (t) => ({
+ overriddenLabel: t('overridden'),
+ resetLabel: t('reset'),
+ invalidLabel: t('invalidNumber'),
+ })
- /** Build a draft of every field from a resolved section. */
- function toDraft(value) {
- const draft = {}
- for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
- return draft
- }
-
- function Field(props) {
- const { field, value, overridden, disabled, onChange } = props
- const common = {
- style: S.input,
- value,
+ /** One staged text row. */
+ function Row(props) {
+ const { t, state, disabled, edit, resetField, meta } = props
+ return h(SettingsValueField, {
+ id: meta.id,
+ label: meta.label,
+ hint: meta.hint,
disabled,
- 'aria-label': field.label,
- onChange: (event) => onChange(event.target.value),
- }
+ numeric: meta.kind === 'number',
+ ...fieldLabels(t),
+ ...state,
+ onEdit: (text) => edit(meta.key, text),
+ onReset: () => resetField(meta.key),
+ })
+ }
+
+ /**
+ * One staged switch row.
+ *
+ * `Checkbox` owns native keyboard and form semantics but no override
+ * affordance, so the badge and the reset sit beside it — the same pair a
+ * `SettingsValueField` draws for a text row.
+ */
+ function SwitchRow(props) {
+ const { t, state, disabled, edit, resetField, meta } = props
+ const checked = state.text === 'true'
return h(
'div',
- { style: S.row },
+ { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
h(
- 'label',
- { style: S.label },
- field.label,
- overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
+ 'div',
+ { style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
+ h(Checkbox, {
+ label: meta.label,
+ checked,
+ disabled,
+ onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
+ }),
+ state.overridden
+ ? h(
+ 'button',
+ {
+ type: 'button',
+ disabled,
+ onClick: () => resetField(meta.key),
+ style: {
+ font: 'inherit',
+ fontSize: '11px',
+ cursor: 'pointer',
+ background: 'transparent',
+ color: 'inherit',
+ opacity: 0.75,
+ border: 'none',
+ padding: 0,
+ },
+ },
+ `${t('overridden')} · ${t('reset')}`,
+ )
+ : null,
),
- field.kind === 'boolean'
- ? h('input', {
- type: 'checkbox',
- checked: value === 'true',
- disabled,
- 'aria-label': field.label,
- onChange: (event) => onChange(event.target.checked ? 'true' : 'false'),
- })
- : h('input', { ...common, type: field.kind === 'password' ? 'password' : 'text' }),
- field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
- )
- }
-
- function Card(props) {
- const scope = props.scope
- const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
- const [draft, setDraft] = react.useState(null)
- const [busy, setBusy] = react.useState(false)
- const [note, setNote] = react.useState('')
- // Collapsed on mount, like every other card in the tab.
- const [open, setOpen] = react.useState(false)
-
- react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
-
- const status = snapshot.status
- const value = snapshot.value
- const baseline = react.useMemo(() => toDraft(value), [value])
- const current = draft ?? baseline
- const user = snapshot.user
-
- if (status === 'unavailable') {
- // A deployment without the Host half, or a remote browser: show no trace.
- return null
- }
-
- const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
- const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
- const writable = snapshot.writable === true
-
- const save = async () => {
- setBusy(true)
- setNote('')
- try {
- for (const field of FIELDS) {
- const next = current[field.key]
- if (next === baseline[field.key]) continue
- // Clearing a field is an unset, not a write of "", so the value
- // re-inherits the composition layer instead of becoming empty.
- if (String(next).trim() === '') await scope.unset(field.key)
- else await scope.set(field.key, fromText(field, next))
- }
- setDraft(null)
- setNote('Saved.')
- // A landed save folds the card back up. A failed or blocked one stays
- // open with the drafts, which is the whole point of staging them.
- setOpen(false)
- } catch (error) {
- setNote(`Save failed: ${error?.message ?? error}`)
- } finally {
- setBusy(false)
- }
- }
-
- const reset = async () => {
- setBusy(true)
- setNote('')
- try {
- for (const key of overriddenKeys) await scope.unset(key)
- setDraft(null)
- setNote('Reset to the deployment defaults.')
- } catch (error) {
- setNote(`Reset failed: ${error?.message ?? error}`)
- } finally {
- setBusy(false)
- }
- }
-
- return h(
- 'li',
- { style: open ? { ...S.card, ...S.cardOpen } : S.card },
- h(
- 'button',
- {
- type: 'button',
- style: S.header,
- 'aria-expanded': open,
- 'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
- onClick: () => setOpen(!open),
- },
- h(
- 'span',
- { style: S.headText },
- h('span', { style: S.name }, TITLE),
- h('span', { style: S.description }, DESCRIPTION),
- ),
- // Marked on the header, so unsaved edits are visible while collapsed.
- dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
- h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
- ),
- open
- ? h(
- 'div',
- { style: S.body },
- writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
- ...FIELDS.map((field) =>
- h(Field, {
- key: field.key,
- field,
- value: current[field.key],
- overridden: overriddenKeys.includes(field.key),
- disabled: busy || !writable,
- onChange: (next) => setDraft({ ...current, [field.key]: next }),
- }),
- ),
- h(
- 'div',
- { style: S.actions },
- h(
- 'button',
- { type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
- dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
- ),
- h(
- 'button',
- { type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
- `Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
- ),
- note === '' ? null : h('span', { style: S.note }, note),
- ),
- )
- : null,
+ h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
)
}
/**
- * Register the card, but only once a settings transport exists.
+ * Render the bundle's page: the form, or nothing while the deployment does
+ * not serve the entry.
*
- * `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
- * because a required dependency that is unsatisfied means this plugin never
- * applies at all — the card would vanish along with everything else instead
- * of simply not appearing.
+ * @param props - the card's state snapshot, the form actions, and locale copy.
+ * @returns the settings form.
+ */
+ function SlopoCard(props) {
+ const { t } = props
+ const state = props.useSlopoCard((snapshot) => snapshot)
+ // `view` is `page` for a bundle's configuration slot; the bundle's
+ // one-liner is the package's own description, so there is no summary view
+ // to answer here.
+ const disabled = !state.writable
+ const children = []
+ for (const field of FIELDS) {
+ const meta = describeField(t, field)
+ const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
+ children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
+ }
+ return h(
+ SettingsForm,
+ { labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
+ h(
+ 'div',
+ { style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
+ // The key is write-only, so it is not one of the rows above.
+ h(SettingsSecretField, {
+ id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`,
+ label: copy(t, API_KEY_FIELD, 'label'),
+ hint: copy(t, API_KEY_FIELD, 'hint'),
+ disabled,
+ text: state.apiKey.text,
+ configured: state.apiKeyConfigured,
+ stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
+ onEdit: (text) => props.edit(API_KEY_FIELD, text),
+ }),
+ ...children,
+ ),
+ )
+ }
+
+ /**
+ * The card's staged form over the `dsh-slopo` profile entry.
+ *
+ * `SettingsFormModel` is what makes Save the only write: a draft is staged
+ * against the revision it was read at, an invalid draft blocks the save
+ * instead of being dropped, and a refused save keeps its drafts so the user
+ * can correct them. The model subscribes to the scope itself and republishes
+ * through {@link SettingsFormModel.bind}, so this class only has to describe
+ * what one snapshot contains.
+ */
+ class SlopoCardController {
+ /**
+ * @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots.
+ * @param scope - the shared configuration form for the `dsh-slopo` entry.
+ */
+ constructor(ctx, scope) {
+ const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor)
+ const secrets = [
+ {
+ field: API_KEY_FIELD,
+ // The credential is a settings field, not a separate credential
+ // reference, so its one-way write is the same fenced mutation the
+ // ordinary rows use — with the value never coming back.
+ write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]),
+ },
+ ]
+ this.ctx = ctx
+ this.form = new SettingsFormModel(scope, specs, secrets)
+ this.store = this.form.bind(() => this.projection())
+ }
+
+ /** Whether the deployment reports a stored key; the value itself never rides. */
+ readApiKeyConfigured() {
+ const view = this.ctx.configForms.describe().getSnapshot().view
+ const entry = view?.namespaces?.find((row) => row.ns === BUNDLE)
+ const slot = entry?.secrets?.find(
+ (secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD,
+ )
+ return slot?.set === true
+ }
+
+ /** Build the snapshot the card renders from the form's current reads. */
+ projection() {
+ const state = {
+ ...this.form.shell(),
+ apiKey: this.form.field(API_KEY_FIELD),
+ apiKeyConfigured: this.readApiKeyConfigured(),
+ }
+ for (const field of FIELDS) {
+ if (field.kind !== 'secret') state[field.key] = this.form.field(field.key)
+ }
+ return state
+ }
+
+ /** Build the face the slot registration injects: the card's state and its actions. */
+ inject() {
+ return { hooks: { slopoCard: this.store }, ...this.form.actions() }
+ }
+
+ /** Release the form's accepted-value subscription. */
+ dispose() {
+ this.form.dispose()
+ }
+ }
+
+ /**
+ * Register the card, but only once the settings transport exists.
+ *
+ * `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a
+ * required dependency that is unsatisfied means this plugin never applies at
+ * all, and the card would simply not exist on a deployment that composes no
+ * settings surface — which is the correct outcome, not a load failure.
+ *
+ * `whileServed` is the second gate. The registration only stands while the
+ * deployment actually serves the `dsh-slopo` namespace, so an uninstalled or
+ * disabled bundle leaves no card behind, and the Plugins page's own
+ * `plugins.bundle.config` ledger stays in step with what the Host serves.
*/
function apply(ctx) {
- ctx.inject(['settingsScope'], (scoped) => {
- const binder = scoped.settingsScope
- if (binder === undefined) return
- const scope = binder.bind({ namespace: NS })
- scoped.slots.inject('settings.plugin.item', () =>
- scoped.slots.register(
- {
- name: 'settings.plugin.item',
- key: NS,
- inject: () => ({ scope }),
- },
- (props) => h(Card, props),
- ),
+ ctx.effect(
+ () =>
+ ctx.locale.register(NS, {
+ en,
+ }),
+ 'dsh-slopo: card dictionary',
+ )
+ ctx.inject(['configForms'], (child) => {
+ const forms = child.configForms
+ if (forms === undefined) return
+ const controller = new SlopoCardController(child, forms.get(BUNDLE))
+ child.effect(
+ () => () => controller.dispose(),
+ 'dsh-slopo: form subscription',
+ )
+ child.effect(
+ () =>
+ forms.whileServed([BUNDLE], () =>
+ child.slots.inject('plugins.bundle.config', () =>
+ child.slots.register(
+ {
+ name: 'plugins.bundle.config',
+ key: BUNDLE,
+ locale: NS,
+ inject: () => controller.inject(),
+ },
+ SlopoCard,
+ ),
+ ),
+ ),
+ 'dsh-slopo: Plugins page card',
)
})
}
- module.exports = { name: NS, inject: ['slots'], apply }
+ module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
return module.exports
},
})
diff --git a/cordis.patch.yml b/cordis.patch.yml
index d135d71..db40fc9 100644
--- a/cordis.patch.yml
+++ b/cordis.patch.yml
@@ -4,8 +4,10 @@
# * three skills on `ctx.skills`, all user-invocable and NOT model-invocable
# (slopo's own agent configs make the same choice, because every one of
# these commands can spend minutes embedding);
-# * a configuration namespace on `ctx.settings`, so the embedding backend is
-# editable at runtime rather than baked into this file;
+# * the `dsh-slopo` configuration, which is this plugin's own exported Config:
+# since dsh 0.1.7-rc.2 the framework projects that schema onto this entry and
+# addresses it by `id`, which is why the id below is also the browser card's
+# slot key. It is editable at runtime rather than baked into this file;
# * one `slopo` tool that resolves the CLI, runs it, and streams progress.
#
# The row references the package by NAME rather than a relative path, so Node
@@ -13,9 +15,10 @@
#
# There is deliberately NO `config:` block here. Every value the plugin needs is
# either discovered at runtime (the slopo binary), set by the user through the
-# settings namespace, or genuinely optional. Shipping defaults would mean baking
-# one machine's paths into a layer that other machines also apply, and the
-# plugin's own schema already carries the defaults it can honestly claim.
+# settings card or the tool's `configure` action, or genuinely optional. Shipping
+# defaults would mean baking one machine's paths into a layer that other machines
+# also apply, and the plugin's own schema already carries the defaults it can
+# honestly claim.
- insert:
- id: dsh-slopo
diff --git a/index.js b/index.js
index c1d5cbf..d2adf34 100644
--- a/index.js
+++ b/index.js
@@ -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/. */
- 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 }
diff --git a/lib/slopo.js b/lib/slopo.js
index 3de4463..81cd6a9 100644
--- a/lib/slopo.js
+++ b/lib/slopo.js
@@ -112,6 +112,48 @@ export function commandLine(argv) {
return argv.map(shellQuote).join(' ')
}
+/**
+ * Run one command through the harness shell seam.
+ *
+ * The seam is `execute(resolve(request))`, whose handle carries the foreground
+ * projection as `result()`. That is deliberately two steps: the handle is the
+ * background-capable object, and awaiting `result()` is what makes a run
+ * foreground. The single-step `run(spec)` this plugin used before
+ * 0.1.7-rc.2 no longer exists on `ShellExecutor`, so calling it threw
+ * `shell.run is not a function` on every action that needed a command.
+ *
+ * `result()` rejects only for infrastructure failures (a spawn that never
+ * produced a process); non-zero exits, timeout kills and abort kills resolve
+ * with a descriptive result. The returned shape is the plugin's own flattened
+ * form, so callers never have to know which generation of the seam they are on.
+ *
+ * @param {{ execute: Function, resolve: Function }} shell the `ctx.shell` service
+ * @param {object} request a `ShellExecRequest`
+ * @returns {Promise<{
+ * exitCode: number | null,
+ * signal: string | null,
+ * timedOut: boolean,
+ * aborted: boolean,
+ * timeoutMs: number,
+ * sandbox: object | undefined,
+ * stdout: { text: string, truncated: boolean },
+ * stderr: { text: string, truncated: boolean },
+ * }>}
+ */
+export async function runShell(shell, request) {
+ const result = await (await shell.execute(shell.resolve(request))).result()
+ return {
+ exitCode: result.exitCode,
+ signal: result.signal,
+ timedOut: result.timedOut,
+ aborted: result.aborted,
+ timeoutMs: result.timeoutMs,
+ sandbox: result.sandbox,
+ stdout: { text: result.stdout.text, truncated: result.stdout.truncated },
+ stderr: { text: result.stderr.text, truncated: result.stderr.truncated },
+ }
+}
+
/**
* Slopo arguments for one action.
*
diff --git a/package.json b/package.json
index 18af6eb..8e6f2bd 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,17 @@
"scripts": {
"test": "node --test test/*.test.js",
"test:release": "bash .gitea/scripts/release.test.sh",
- "release:plan": "bash .gitea/scripts/release.sh should-release \"$(bash .gitea/scripts/release.sh range)\""
+ "release:plan": "bash .gitea/scripts/release.sh should-release \"$(bash .gitea/scripts/release.sh range)\"",
+ "release:notes": "bash .gitea/scripts/release.sh notes \"$(bash .gitea/scripts/release.sh range)\""
+ },
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "^4.0.4",
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.7-rc.2",
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.2",
+ "@deepseek-ai/dsh-skill": ">=0.1.7-rc.2",
+ "@deepseek-ai/dsh-tools": ">=0.1.7-rc.2",
+ "@deepseek-ai/schemastery": "^3.18.4",
+ "react": "^18.3.1"
},
"dsh": {
"bundle": {
@@ -41,9 +51,15 @@
"client": {
"platform": "web",
"inject": [
- "@deepseek-ai/dsh-client-ui-slots",
+ "@deepseek-ai/dsh-client-locale",
+ "@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-settings"
]
+ },
+ "compatibility": {
+ "dshReleases": {
+ "0.1.7-rc.2": "compatible"
+ }
}
},
"keywords": [
diff --git a/test/client.test.js b/test/client.test.js
index 433cfb8..2dd0a0f 100644
--- a/test/client.test.js
+++ b/test/client.test.js
@@ -3,76 +3,478 @@
*
* There is no browser here, so the bundle is evaluated the way the real module
* loader would — inside `window.__ModuleLoader__.load` — with a fake `require`
- * that supplies React and THROWS on anything else. That last part is the useful
- * one: it proves the bundle stays inside the platform's seed table, which is the
- * constraint a hand-written client half is most likely to break.
+ * that supplies React and `@deepseek-ai/dsh-client-ui-primitives` and THROWS on
+ * anything else. That last part is the useful one: it proves the bundle stays
+ * inside the platform's seed table, which is the constraint a hand-written
+ * client half is most likely to break.
*
- * The card is then rendered by calling the registered component and expanding
- * the elements it returns, and its handlers are driven directly. That is not a
- * substitute for looking at it in a browser, but it does check the wiring the
- * card cannot work without — including the disclosure chrome, which is what
- * makes it look like the other cards in the tab.
+ * Two things are therefore stubbed, and it is worth being plain about what that
+ * does and does not prove:
+ *
+ * * React. The card itself calls no hooks — the shared primitives do — so the
+ * double only has to implement `createElement`, and component resolution is
+ * the tree walk below.
+ * * `@deepseek-ai/dsh-client-ui-primitives`. It is a real ESM package that
+ * imports React, `clsx`, zustand and thirty stylesheets, so it cannot be
+ * loaded under plain Node; the double reproduces the shipped
+ * `SettingsFormModel` staging semantics (read back from the package's own
+ * `lib/index.js`) closely enough that this card's field specs — which are
+ * the part this repo owns — are exercised for real, and renders the shared
+ * components as their documented props describe.
+ *
+ * `docs-contract` at the bottom of this file covers the other direction: that
+ * the slot and service names this bundle binds to are the ones the installed
+ * harness actually ships.
*/
import assert from 'node:assert/strict'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
import { test } from 'node:test'
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
+/** Where a global dsh installation keeps its packages, if there is one. */
+const DSH_PACKAGES = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai'
+
+// ---------------------------------------------------------------------------
+// The two doubles
+// ---------------------------------------------------------------------------
+
/**
- * Minimal React: createElement, the three hooks the card uses, and an effect
- * runner that actually invokes its callback rather than ignoring it.
+ * The shipped `SettingsFormModel`, reduced to the reads and the one write this
+ * card drives. Staging, the plan, the revision fence and the "a refused save
+ * keeps its drafts" rule are the shipped behaviour, not an invention: they are
+ * the reason the card uses this model instead of writing on every keystroke.
*
- * `useState` consumes one ordered slot per call, as real hooks do.
+ * A store is a plain snapshot holder here; the shipped one is a uSES-compatible
+ * observable, which is all `store.set`/`getSnapshot` need to stand in for.
*/
-function makeFakeReact() {
- const slots = []
- let cursor = 0
- return {
- reset() {
- cursor = 0
- },
- api: {
- createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }),
- useState(initial) {
- const index = cursor
- cursor += 1
- if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial
- return [
- slots[index],
- (next) => {
- slots[index] = typeof next === 'function' ? next(slots[index]) : next
- },
- ]
+class SettingsFormModel {
+ constructor(scope, specs, secrets = []) {
+ this.scope = scope
+ this.specs = new Map(specs.map((spec) => [spec.field, spec]))
+ this.secretSpecs = new Map(secrets.map((spec) => [spec.field, spec]))
+ this.staged = new Map()
+ this.listeners = new Set()
+ this.saving = false
+ this.failed = false
+ this.unsubscribe = scope.subscribe(() => this.publish())
+ }
+
+ bind(project) {
+ const store = {
+ snapshot: project(),
+ getSnapshot() {
+ return this.snapshot
},
- useEffect(effect) {
- effect()
+ set(next) {
+ this.snapshot = next
},
- useMemo(factory) {
- return factory()
+ subscribe() {
+ return () => {}
},
- },
+ }
+ this.listeners.add(() => store.set(project()))
+ return store
+ }
+
+ shell() {
+ const snapshot = this.scope.getSnapshot()
+ const plan = this.plan()
+ return {
+ available: snapshot.status === 'ready',
+ writable: snapshot.writable,
+ dirty: plan.length > 0,
+ invalid: plan.some((item) => item.run === undefined && item.op === undefined),
+ saving: this.saving,
+ failed: this.failed,
+ }
+ }
+
+ field(field) {
+ const staged = this.staged.get(field)
+ if (this.secretSpecs.has(field)) {
+ return { text: staged?.text ?? '', overridden: false, invalid: false }
+ }
+ const spec = this.spec(field)
+ if (staged === undefined) {
+ return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
+ }
+ const write = staged.clear ? { kind: 'clear' } : spec.parse(staged.text)
+ return { text: staged.text, overridden: write?.kind === 'set', invalid: write === undefined }
+ }
+
+ actions() {
+ return {
+ edit: (field, text) => this.stage(field, { text, clear: false }),
+ resetField: (field) => this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }),
+ save: () => this.save(),
+ discard: () => {
+ if (this.staged.size === 0 && !this.failed) return
+ this.staged.clear()
+ this.baseline = undefined
+ this.failed = false
+ this.publish()
+ },
+ }
+ }
+
+ async save() {
+ const plan = this.plan()
+ if (!plan.length || this.saving || !this.scope.getSnapshot().writable) return
+ if (plan.some((item) => item.run === undefined && item.op === undefined)) return
+ this.saving = true
+ this.failed = false
+ this.publish()
+ try {
+ const ops = plan.flatMap((item) => (item.op === undefined ? [] : [item.op]))
+ let landed = !ops.length || (await this.scope.mutate(ops, this.baseline?.revision))
+ if (!landed) {
+ this.failed = true
+ return
+ }
+ for (const item of plan) if (item.run) landed = (await item.run()) && landed
+ if (landed) {
+ this.staged.clear()
+ this.baseline = undefined
+ }
+ this.failed = !landed
+ } catch {
+ this.failed = true
+ } finally {
+ this.saving = false
+ this.publish()
+ }
+ }
+
+ plan() {
+ const plan = []
+ for (const [field, staged] of this.staged) {
+ const secret = this.secretSpecs.get(field)
+ if (secret !== undefined) {
+ const value = staged.text.trim()
+ if (value !== '') plan.push({ field, run: () => secret.write(value) })
+ continue
+ }
+ const spec = this.spec(field)
+ if (staged.clear) {
+ if (this.stored(field)) plan.push({ field, op: { op: 'unset', path: [field] } })
+ continue
+ }
+ if (staged.text === spec.format(this.sectionValue(field))) continue
+ const write = spec.parse(staged.text)
+ if (write === undefined) plan.push({ field })
+ else if (write.kind === 'clear') plan.push({ field, op: { op: 'unset', path: [field] } })
+ else plan.push({ field, op: { op: 'set', path: [field], value: write.value } })
+ }
+ return plan
+ }
+
+ stage(field, edit) {
+ this.baseline ??= this.scope.getSnapshot()
+ this.staged.set(field, edit)
+ this.failed = false
+ this.publish()
+ }
+
+ publish() {
+ for (const listener of this.listeners) listener()
+ }
+
+ dispose() {
+ this.unsubscribe()
+ this.listeners.clear()
+ }
+
+ spec(field) {
+ const spec = this.specs.get(field)
+ if (spec === undefined) throw new Error(`no spec for "${field}"`)
+ return spec
+ }
+
+ sectionValue(field) {
+ return this.scope.getSnapshot().value?.[field]
+ }
+
+ baseValue(field) {
+ return this.scope.getSnapshot().base?.[field]
+ }
+
+ userLayer() {
+ const user = this.scope.getSnapshot().user
+ return user !== null && typeof user === 'object' ? user : {}
+ }
+
+ stored(field) {
+ return Object.hasOwn(this.userLayer(), field)
}
}
+/** The shipped number and text specs, verbatim. */
+const numberField = (field) => ({
+ field,
+ format: (value) => (typeof value === 'number' ? String(value) : ''),
+ parse: (text) => {
+ const trimmed = text.trim()
+ if (trimmed === '') return { kind: 'clear' }
+ const parsed = Number(trimmed)
+ return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
+ },
+})
+
+const textField = (field) => ({
+ field,
+ format: (value) => (typeof value === 'string' ? value : ''),
+ parse: (text) => {
+ const trimmed = text.trim()
+ return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
+ },
+})
+
+/**
+ * The shared components, rendered as the props they document.
+ *
+ * Every one of them is a plain function, so expanding the tree never needs a
+ * hook runtime: the card's own composition is what is under test.
+ */
+function primitivesDouble() {
+ const h = (type, props, ...children) => ({ type, props: props ?? {}, children })
+
+ const SettingsForm = (props) =>
+ h(
+ 'form',
+ {
+ 'data-labels': JSON.stringify(props.labels),
+ // The real form disables Save until something is staged, nothing is
+ // invalid, no write is in flight, and the document accepts writes.
+ 'data-can-save': String(props.state.dirty && !props.state.invalid && !props.state.saving && props.state.writable),
+ onSave: props.onSave,
+ onDiscard: props.onDiscard,
+ },
+ h('button', { type: 'button', disabled: !(props.state.dirty && !props.state.invalid && props.state.writable), onClick: props.onSave }, props.labels.save),
+ props.children,
+ )
+
+ const SettingsValueField = (props) =>
+ h(
+ 'div',
+ { 'data-field': props.id },
+ h('label', { htmlFor: props.id }, props.label),
+ h('input', {
+ id: props.id,
+ type: 'text',
+ value: props.text,
+ disabled: props.disabled,
+ 'aria-label': props.label,
+ 'data-invalid': String(props.invalid),
+ 'data-overridden': String(props.overridden),
+ onChange: (event) => props.onEdit(event.target.value),
+ }),
+ props.overridden ? h('button', { type: 'button', disabled: props.disabled, onClick: props.onReset }, props.resetLabel) : null,
+ h('div', { 'data-hint': true }, props.invalid ? props.invalidLabel : props.hint),
+ )
+
+ const SettingsSecretField = (props) =>
+ h(
+ 'div',
+ { 'data-field': props.id },
+ h('label', { htmlFor: props.id }, props.label),
+ h('input', {
+ id: props.id,
+ type: 'password',
+ value: props.text,
+ disabled: props.disabled,
+ 'aria-label': props.label,
+ autoComplete: 'off',
+ onChange: (event) => props.onEdit(event.target.value),
+ }),
+ h('div', { 'data-state': true }, props.stateLabel),
+ h('div', { 'data-configured': String(props.configured) }),
+ )
+
+ // The real primitive is a labelled checkbox: the label WRAPS the control.
+ const Checkbox = (props) =>
+ h(
+ 'label',
+ {},
+ h('input', {
+ type: 'checkbox',
+ checked: Boolean(props.checked),
+ disabled: props.disabled,
+ 'aria-label': props.label,
+ onChange: (event) => props.onChange(event.target.checked),
+ }),
+ props.label,
+ )
+
+ return { SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField, Checkbox, settingsNumberField: numberField, settingsTextField: textField }
+}
+
+/**
+ * Minimal React: `createElement` is the only hook-free seam the card needs.
+ *
+ * Children land in `props.children` as well as on the wrapper, because that is
+ * where a real element carries them and what the shared components read.
+ */
+const fakeReact = {
+ createElement: (type, props, ...children) => {
+ const merged = { ...(props ?? {}) }
+ if (children.length > 0) merged.children = children.length === 1 ? children[0] : children
+ return { type, props: merged, children }
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
/** Evaluate the bundle against a fake loader and return its exports. */
-function loadBundle() {
+function loadBundle(requireImpl) {
let loaded
const window = { __ModuleLoader__: { load: (spec) => { loaded = spec } } }
new Function('window', SOURCE)(window)
assert.ok(loaded !== undefined, 'the bundle must call window.__ModuleLoader__.load')
- const react = makeFakeReact()
- const exported = loaded.factory((name) => {
- if (name === 'react') return react.api
- throw new Error(`the client bundle required "${name}", which is not in the platform seed table`)
- })
- return { id: loaded.id, exported, react }
+ const primitives = primitivesDouble()
+ const exported = loaded.factory(
+ requireImpl ??
+ ((name) => {
+ if (name === 'react') return fakeReact
+ if (name === 'react/jsx-runtime') return { jsx: fakeReact.createElement, jsxs: fakeReact.createElement, Fragment: 'Fragment' }
+ if (name === '@deepseek-ai/dsh-client-ui-primitives') return primitives
+ throw new Error(`the client bundle required "${name}", which is not in the platform seed table`)
+ }),
+ )
+ return { id: loaded.id, exported, primitives }
}
+const READY = {
+ status: 'ready',
+ value: {
+ embeddingModel: 'ollama/example:latest',
+ embeddingDimensions: 768,
+ embeddingApiBase: 'http://127.0.0.1:11434',
+ embeddingParamsJson: '{}',
+ binary: '',
+ autoInstall: true,
+ sourceDir: '',
+ sourceDirExclude: ['**/dist/**'],
+ useDefaultExcludes: true,
+ maxIndexUnits: 5000,
+ ignoreFile: '',
+ stateDir: '',
+ embeddingRequestDelay: 0,
+ timeoutMs: 600_000,
+ },
+ base: {},
+ user: {},
+ revision: 7,
+ writable: true,
+ mode: 'host',
+}
+
+/** A settings scope stand-in recording the mutations it was asked to make. */
+function fakeScope(snapshot = READY, { landed = true } = {}) {
+ const calls = { mutate: [], subscriptions: 0 }
+ return {
+ calls,
+ getSnapshot: () => snapshot,
+ subscribe(listener) {
+ calls.subscriptions += 1
+ return () => {}
+ },
+ async mutate(ops, revision) {
+ calls.mutate.push({ ops, revision })
+ return landed
+ },
+ }
+}
+
+/**
+ * Register the card the way the host would, and hand back everything a test
+ * needs to drive it: the registration options, the injected face, and a render
+ * function.
+ */
+function registered({ snapshot = READY, served = true, secrets = [], landed = true } = {}) {
+ const { id, exported, primitives } = loadBundle()
+ const scope = fakeScope(snapshot, { landed })
+ const effects = []
+ const injects = []
+ const whileServed = []
+ const dictionaries = new Map()
+ let registration
+ let component
+
+ const configForms = {
+ get: (ns) => {
+ assert.equal(ns, 'dsh-slopo')
+ return scope
+ },
+ whileServed: (namespaces, register) => {
+ whileServed.push(namespaces)
+ const off = served ? register(new Set(namespaces)) : undefined
+ return () => off?.()
+ },
+ describe: () => ({
+ getSnapshot: () => ({ view: { namespaces: [{ ns: 'dsh-slopo', secrets }] } }),
+ }),
+ }
+
+ /** A `ctx.inject` child: an effect scope plus the slots registry. */
+ const childEffect = (effect, _label) => {
+ effects.push(effect())
+ }
+ const ctx = {
+ effect: childEffect,
+ locale: {
+ register(ns, dicts) {
+ dictionaries.set(ns, dicts.en)
+ return () => {}
+ },
+ bind: (ns) => {
+ const dict = dictionaries.get(ns) ?? {}
+ return (key) => dict[key] ?? key
+ },
+ },
+ inject(deps, callback) {
+ injects.push(deps)
+ callback({
+ configForms,
+ effect: childEffect,
+ slots: {
+ inject(_name, produce) {
+ produce()
+ return () => {}
+ },
+ register(options, render) {
+ registration = options
+ component = render
+ },
+ },
+ })
+ },
+ }
+
+ exported.apply(ctx)
+
+ const t = ctx.locale.bind('dsh-slopo')
+ const injectFace = () => ({ t: (key) => t(key), ...registration.inject() })
+ const render = (extra = {}) => {
+ const face = { ...injectFace(), ...extra }
+ const state = face.hooks.slopoCard.getSnapshot()
+ return component({ ...face, view: 'page', useSlopoCard: (select) => select(state) })
+ }
+
+ return { id, exported, primitives, scope, effects, injects, whileServed, registration: () => registration, render, t }
+}
+
+// ---------------------------------------------------------------------------
+// Tree helpers
+// ---------------------------------------------------------------------------
+
/** Resolve function components by calling them, until a host element or null. */
function expand(node, depth = 0) {
- if (depth > 50) throw new Error('the component tree did not settle')
+ if (depth > 60) throw new Error('the component tree did not settle')
if (node === null || node === undefined) return null
if (typeof node !== 'object') return node
if (Array.isArray(node)) return node
@@ -97,83 +499,14 @@ function findAll(node, predicate, out = []) {
return out
}
-function textOf(node) {
- if (typeof node === 'string') return node
- if (node === null || node === undefined || typeof node !== 'object') return ''
- if (Array.isArray(node)) return node.map(textOf).join('')
- return (node.children ?? []).map(textOf).join('')
-}
-
-/** A SettingsScope stand-in recording the writes it was asked to make. */
-function fakeScope(snapshot) {
- const calls = { set: [], unset: [] }
- return {
- calls,
- getSnapshot: () => snapshot,
- subscribe: () => () => {},
- set: async (field, value) => {
- calls.set.push([field, value])
- },
- unset: async (field) => {
- calls.unset.push(field)
- },
- }
-}
-
-const READY = {
- status: 'ready',
- value: { embeddingModel: '', embeddingDimensions: 0 },
- user: {},
- base: {},
- revision: 1,
- writable: true,
- mode: 'host',
-}
-
-/** Register the card the way the host would, and hand back its component. */
-function registered() {
- const { id, exported, react } = loadBundle()
- let registration
- let render
- const ctx = {
- inject(deps, callback) {
- assert.deepEqual(deps, ['settingsScope'])
- callback({
- settingsScope: { bind: () => fakeScope(READY) },
- slots: {
- inject(_name, produce) {
- produce()
- },
- register(options, component) {
- registration = options
- render = component
- },
- },
- })
- },
- }
- exported.apply(ctx)
- const draw = (scope) => {
- react.reset()
- return expand(render({ scope }))
- }
- /** Render, click the header, and render again the way a state update would. */
- const drawOpen = (scope) => {
- const header = headerOf(draw(scope))
- header.props.onClick()
- return draw(scope)
- }
- return { id, exported, react, registration, draw, drawOpen }
-}
-
-const headerOf = (tree) =>
- findAll(tree, (node) => node.type === 'button' && node.props['aria-expanded'] !== undefined)[0]
-
const inputFor = (tree, label) =>
findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
-const buttonStartingWith = (tree, prefix) =>
- findAll(tree, (node) => node.type === 'button' && textOf(node).startsWith(prefix))[0]
+const saveButton = (tree) => findAll(tree, (node) => node.type === 'button' && node.children.includes('Save'))[0]
+
+// ---------------------------------------------------------------------------
+// Bundle identity and the seed table
+// ---------------------------------------------------------------------------
test('the bundle id matches the package name the loader is told about', () => {
const { id } = loadBundle()
@@ -181,39 +514,87 @@ test('the bundle id matches the package name the loader is told about', () => {
})
test('the client bundle requires nothing outside the platform seed table', () => {
- // loadBundle() throws on any specifier other than react.
+ // loadBundle() throws on any specifier other than the two seed words.
assert.doesNotThrow(() => loadBundle())
})
-test('applying registers exactly one settings.plugin.item card, keyed on the namespace', () => {
+test('the bundle exports the package name and the services it needs', () => {
+ const { exported } = loadBundle()
+ assert.equal(exported.name, 'dsh-slopo')
+ // `locale` is required because the card's copy rides a dictionary; `slots`
+ // because the registration is a slot.
+ assert.deepEqual(exported.inject, ['slots', 'locale'])
+})
+
+// ---------------------------------------------------------------------------
+// The registration contract — the part that regressed
+// ---------------------------------------------------------------------------
+
+test('the card registers into plugins.bundle.config, keyed by the bundle package name', () => {
const { registration, exported } = registered()
assert.equal(exported.name, 'dsh-slopo')
- assert.deepEqual(exported.inject, ['slots'])
- assert.equal(registration.name, 'settings.plugin.item')
- // The key must equal the Host namespace, or the tab never dispatches the card.
- assert.equal(registration.key, 'dsh-slopo')
+ assert.equal(registration().name, 'plugins.bundle.config')
+ // The key must equal the bundle's package name: the Plugins page dispatches
+ // this slot by `pkg.name`, and nothing else.
+ assert.equal(registration().key, 'dsh-slopo')
+ // `locale` is what injects the card's `t`.
+ assert.equal(registration().locale, 'dsh-slopo')
})
-test('the root is an , because the tab renders every card into a ', () => {
- const { draw } = registered()
- assert.equal(draw(fakeScope(READY)).type, 'li')
+test('the card no longer binds the pre-0.1.7 settings surface', () => {
+ // `settings.plugin.item` and the `settingsScope` service were removed from the
+ // harness; a bundle that still names them renders nothing at all, and does it
+ // silently. The header comment names them on purpose, so the scan is over the
+ // code: strip comments first, then look.
+ const code = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ assert.doesNotMatch(code, /settingsScope/)
+ assert.doesNotMatch(code, /settings\.plugin\.item/)
+ assert.doesNotMatch(code, /installSection/)
})
-test('the card starts collapsed, with a header that says so', () => {
- const { draw } = registered()
- const tree = draw(fakeScope(READY))
- assert.equal(headerOf(tree).props['aria-expanded'], false)
- assert.match(headerOf(tree).props['aria-label'], /^Expand: /)
- // Nothing is disclosed yet.
- assert.equal(findAll(tree, (node) => node.type === 'input').length, 0)
+test('the card asks for configForms, and does it optionally', () => {
+ const { injects } = registered()
+ assert.deepEqual(injects, [['configForms']])
})
-test('clicking the header discloses every setting the Host accepts', () => {
- const { drawOpen } = registered()
- const tree = drawOpen(fakeScope(READY))
- assert.equal(headerOf(tree).props['aria-expanded'], true)
- assert.match(headerOf(tree).props['aria-label'], /^Collapse: /)
- const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
+test('the registration stands only while the Host serves the namespace', () => {
+ const served = registered({ served: true })
+ assert.deepEqual(served.whileServed, [['dsh-slopo']])
+ assert.ok(served.registration() !== undefined)
+
+ // A deployment that does not serve the entry leaves no card behind.
+ const unserved = registered({ served: false })
+ assert.equal(unserved.registration(), undefined)
+})
+
+test('the injected face carries the card store and the form actions', () => {
+ const { registration } = registered()
+ const face = registration().inject()
+ assert.ok(face.hooks.slopoCard !== undefined, 'the card reads its state through a hook')
+ for (const action of ['edit', 'resetField', 'save', 'discard']) {
+ assert.equal(typeof face[action], 'function', `missing form action "${action}"`)
+ }
+})
+
+test('teardown releases the form subscription', () => {
+ const { scope, effects } = registered()
+ assert.equal(scope.calls.subscriptions, 1)
+ // The second effect is the whileServed one; the first disposes the form.
+ assert.equal(typeof effects[0], 'function')
+ effects[0]()
+ // Disposing twice is not the card's business, but the disposer must exist and
+ // must not throw before the form's own disposer has a chance to run.
+ assert.doesNotThrow(() => effects[1]())
+})
+
+// ---------------------------------------------------------------------------
+// The card's controls
+// ---------------------------------------------------------------------------
+
+test('the card renders every setting the Host schema declares', () => {
+ const { render } = registered()
+ const tree = render()
+ const labels = findAll(tree, (node) => node.type === 'label').map((node) => node.children.join(''))
for (const expected of [
'Embedding model',
'Embedding dimensions',
@@ -221,136 +602,219 @@ test('clicking the header discloses every setting the Host accepts', () => {
'Embedding API key',
'Extra embedding parameters',
'slopo executable',
+ 'Install slopo with uv when missing',
'Source directory',
'Extra exclude patterns',
- 'State directory',
+ 'Exclude node_modules and build output',
+ 'Refuse to embed more than this many units',
'Ignore file',
+ 'State directory',
+ 'Delay between embedding requests',
'Foreground timeout',
]) {
- assert.ok(
- labels.some((label) => label.includes(expected)),
- `no control labelled "${expected}"`,
- )
+ assert.ok(labels.some((label) => label.includes(expected)), `no control labelled "${expected}"`)
}
})
-test('the API key is rendered as a password field', () => {
- const { drawOpen } = registered()
- const tree = drawOpen(fakeScope(READY))
- assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password')
+test('the API key is a write-only password control that reports only whether one is stored', () => {
+ const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: true }] })
+ const tree = render()
+ const input = inputFor(tree, 'Embedding API key')
+ assert.equal(input.props.type, 'password')
+ assert.equal(input.props.value, '', 'a secret never rides back into the control')
+ assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /A key is stored/)
})
-test('the card renders nothing when the namespace is unavailable', () => {
- const { draw } = registered()
- assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null)
+test('a deployment with no key says so', () => {
+ const { render } = registered({ secrets: [{ path: ['embeddingApiKey'], set: false }] })
+ const tree = render()
+ assert.equal(findAll(tree, (node) => node.props['data-configured'] === 'true').length, 0)
+ assert.match(findAll(tree, (node) => node.props['data-state'] === true).map((node) => node.children.join('')).join(''), /No key is stored/)
})
-test('editing a field marks the header unsaved, and saving writes it through set()', async () => {
- const { draw, drawOpen, react } = registered()
- const scope = fakeScope(READY)
+test('the current value of every ordinary field is rendered', () => {
+ const { render } = registered()
+ const tree = render()
+ assert.equal(inputFor(tree, 'Embedding model').props.value, 'ollama/example:latest')
+ assert.equal(inputFor(tree, 'Embedding dimensions').props.value, '768')
+ assert.equal(inputFor(tree, 'Extra exclude patterns').props.value, '**/dist/**')
+ assert.equal(inputFor(tree, 'Install slopo with uv when missing').props.checked, true)
+})
- const open = drawOpen(scope)
- inputFor(open, 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } })
+test('a read-only deployment disables every control', () => {
+ const { render } = registered({ snapshot: { ...READY, writable: false, mode: 'memory' } })
+ const tree = render()
+ const controls = findAll(tree, (node) => node.type === 'input')
+ assert.ok(controls.length >= 15)
+ for (const control of controls) assert.equal(control.props.disabled, true, control.props['aria-label'])
+ assert.equal(saveButton(tree).props.disabled, true)
+})
- const tree = draw(scope)
- // Marked on the header, so it is visible while collapsed.
- assert.match(textOf(headerOf(tree)), /unsaved/)
- const save = buttonStartingWith(tree, 'Save')
- assert.equal(save.props.disabled, false, 'a changed field must enable Save')
+test('the card hands an unavailable namespace to the shared form instead of hiding', () => {
+ // The form is what swaps in the "not served" line, from `state.available`.
+ // Hiding here would leave the Plugins page with a configured-but-empty card.
+ const { render } = registered({ snapshot: { ...READY, status: 'unavailable' } })
+ const form = expand(render())
+ assert.equal(JSON.parse(form.props['data-labels']).unavailable.startsWith('This plugin is not loaded'), true)
+})
+
+// ---------------------------------------------------------------------------
+// Saving: the field specs this repo owns
+// ---------------------------------------------------------------------------
+
+/** Stage one edit, then click Save the way the form does. */
+async function stageAndSave(render, label, next) {
+ const input = inputFor(render(), label)
+ input.props.onChange({ target: { value: next } })
+ const form = render()
+ const save = saveButton(form)
+ assert.equal(save.props.disabled, false, 'a staged edit must enable Save')
await save.props.onClick()
+}
- assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']])
- assert.deepEqual(scope.calls.unset, [])
- assert.equal(react !== undefined, true)
-})
-
-test('a successful save collapses the card again', async () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope(READY)
- inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/x:latest' } })
- await buttonStartingWith(draw(scope), 'Save').props.onClick()
-
- // The save sets busy=false and note='Saved.', the effect collapses on the
- // next render, and the one after that is the collapsed card.
- draw(scope)
- const settled = draw(scope)
- assert.equal(headerOf(settled).props['aria-expanded'], false, 'a landed save closes the card')
- assert.equal(findAll(settled, (node) => node.type === 'input').length, 0)
-})
-
-test('staged edits survive collapsing', () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope(READY)
- inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/kept:latest' } })
- // Collapse it by hand.
- headerOf(draw(scope)).props.onClick()
- const collapsed = draw(scope)
- assert.equal(headerOf(collapsed).props['aria-expanded'], false)
- assert.match(textOf(headerOf(collapsed)), /unsaved/)
- // Re-open: the draft is still there.
- headerOf(collapsed).props.onClick()
- assert.equal(inputFor(draw(scope), 'Embedding model').props.value, 'ollama/kept:latest')
+test('Save sends one fenced mutation, at the revision the drafts were read from', async () => {
+ const { render, scope } = registered()
+ await stageAndSave(render, 'Embedding model', 'ollama/other:latest')
+ assert.equal(scope.calls.mutate.length, 1)
+ assert.deepEqual(scope.calls.mutate[0].ops, [
+ { op: 'set', path: ['embeddingModel'], value: 'ollama/other:latest' },
+ ])
+ assert.equal(scope.calls.mutate[0].revision, 7)
})
test('a whole number field is sent as a number, not the string the input holds', async () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope(READY)
- inputFor(drawOpen(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
- await buttonStartingWith(draw(scope), 'Save').props.onClick()
- assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]])
+ const { render, scope } = registered()
+ await stageAndSave(render, 'Embedding dimensions', '1024')
+ assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['embeddingDimensions'], value: 1024 }])
})
-test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } })
- inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: '' } })
- await buttonStartingWith(draw(scope), 'Save').props.onClick()
- assert.deepEqual(scope.calls.unset, ['embeddingModel'])
- assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string')
+test('a number that is not a number blocks the save instead of being dropped', async () => {
+ const { render, scope } = registered()
+ const tree = render()
+ inputFor(tree, 'Embedding dimensions').props.onChange({ target: { value: 'not-a-number' } })
+ const form = render()
+ assert.equal(saveButton(form).props.disabled, true, 'an invalid draft must disable Save')
+ await saveButton(form).props.onClick()
+ assert.deepEqual(scope.calls.mutate, [])
+ // …and the field says why.
+ assert.equal(inputFor(form, 'Embedding dimensions').props['data-invalid'], 'true')
})
test('an exclude list round-trips through comma-separated text', async () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } })
- inputFor(drawOpen(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
- await buttonStartingWith(draw(scope), 'Save').props.onClick()
- assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]])
+ const { render, scope } = registered()
+ await stageAndSave(render, 'Extra exclude patterns', '**/test/**, *.min.js')
+ assert.deepEqual(scope.calls.mutate[0].ops, [
+ { op: 'set', path: ['sourceDirExclude'], value: ['**/test/**', '*.min.js'] },
+ ])
})
-test('a boolean field writes a real boolean', async () => {
- const { draw, drawOpen } = registered()
- const scope = fakeScope(READY)
- const checkbox = findAll(
- drawOpen(scope),
- (node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing',
- )[0]
- checkbox.props.onChange({ target: { checked: true } })
- await buttonStartingWith(draw(scope), 'Save').props.onClick()
- assert.deepEqual(scope.calls.set, [['autoInstall', true]])
+test('a switch writes a real boolean', async () => {
+ const { render, scope } = registered()
+ const tree = render()
+ inputFor(tree, 'Install slopo with uv when missing').props.onChange({ target: { checked: false } })
+ await saveButton(render()).props.onClick()
+ assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['autoInstall'], value: false }])
})
-test('an overridden field is marked, and Reset clears every override', async () => {
- const { drawOpen } = registered()
- const scope = fakeScope({
- ...READY,
- value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
- user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
+test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
+ const { render, scope } = registered({
+ snapshot: { ...READY, user: { embeddingModel: 'ollama/example:latest' } },
})
- const tree = drawOpen(scope)
- const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
- assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked')
-
- const reset = buttonStartingWith(tree, 'Reset')
- assert.equal(reset.props.disabled, false)
- await reset.props.onClick()
- assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel'])
+ await stageAndSave(render, 'Embedding model', '')
+ assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'unset', path: ['embeddingModel'] }])
})
-test('a read-only namespace says so and disables every control', () => {
- const { drawOpen } = registered()
- const tree = drawOpen(fakeScope({ ...READY, writable: false, mode: 'memory' }))
- assert.match(textOf(tree), /read-only/)
- const inputs = findAll(tree, (node) => node.type === 'input')
- assert.ok(inputs.length > 0)
- for (const input of inputs) assert.equal(input.props.disabled, true)
+test('an overridden field is marked and offers its own reset', async () => {
+ const { render, scope } = registered({
+ snapshot: {
+ ...READY,
+ base: { embeddingDimensions: 0 },
+ user: { embeddingDimensions: 768 },
+ },
+ })
+ const tree = render()
+ const field = inputFor(tree, 'Embedding dimensions')
+ assert.equal(field.props['data-overridden'], 'true')
+ const reset = findAll(tree, (node) => node.type === 'button' && node.children.includes('Reset to default'))[0]
+ assert.ok(reset !== undefined, 'an overridden field must offer a reset')
+ reset.props.onClick()
+ await saveButton(render()).props.onClick()
+ assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'unset', path: ['embeddingDimensions'] }])
})
+
+test('a secret draft is written one way, and a blank one writes nothing', async () => {
+ const { render, scope } = registered()
+ inputFor(render(), 'Embedding API key').props.onChange({ target: { value: 'sk-secret' } })
+ await saveButton(render()).props.onClick()
+ assert.deepEqual(scope.calls.mutate[0].ops, [{ op: 'set', path: ['embeddingApiKey'], value: 'sk-secret' }])
+
+ const blank = registered()
+ inputFor(blank.render(), 'Embedding API key').props.onChange({ target: { value: ' ' } })
+ await saveButton(blank.render()).props.onClick()
+ assert.deepEqual(blank.scope.calls.mutate, [], 'a blank secret draft must not clear the stored key')
+})
+
+test('a refused save keeps its drafts so they can be corrected', async () => {
+ const { render, scope } = registered({ landed: false })
+ const tree = render()
+ inputFor(tree, 'Embedding model').props.onChange({ target: { value: 'ollama/try-again:latest' } })
+ await saveButton(render()).props.onClick()
+ assert.equal(scope.calls.mutate.length, 1)
+ // Still dirty: nothing was accepted, so nothing is thrown away.
+ assert.equal(saveButton(render()).props.disabled, false)
+ assert.equal(inputFor(render(), 'Embedding model').props.value, 'ollama/try-again:latest')
+})
+
+// ---------------------------------------------------------------------------
+// Contract against the installed harness
+// ---------------------------------------------------------------------------
+
+const harnessPresent = existsSync(DSH_PACKAGES)
+
+test(
+ 'the slot this card binds to is declared by the installed harness',
+ { skip: harnessPresent ? false : 'no global dsh installation to read the slot catalog from' },
+ () => {
+ const catalog = readFileSync(join(DSH_PACKAGES, 'dsh-cordis-client-runner/lib/client.js'), 'utf8')
+ assert.match(catalog, /key: "plugins\.bundle\.config"/, 'plugins.bundle.config must exist in the slot catalog')
+ assert.doesNotMatch(catalog, /key: "settings\.plugin\.item"/, 'settings.plugin.item must be gone')
+ },
+)
+
+test(
+ 'every primitive the card requires is exported by the installed package',
+ { skip: harnessPresent ? false : 'no global dsh installation to read the primitives package from' },
+ () => {
+ const bundle = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-primitives/lib/index.js'), 'utf8')
+ const exported = new Set(
+ (bundle.match(/export \{([^}]*)\};/g) ?? [])
+ .flatMap((line) => line.slice(8, -2).split(','))
+ .map((name) => name.trim().split(/\s+as\s+/).pop()),
+ )
+ for (const name of [
+ 'Checkbox',
+ 'SettingsForm',
+ 'SettingsFormModel',
+ 'SettingsSecretField',
+ 'SettingsValueField',
+ 'settingsNumberField',
+ 'settingsTextField',
+ ]) {
+ assert.ok(exported.has(name), `@deepseek-ai/dsh-client-ui-primitives does not export "${name}"`)
+ }
+ // The card reads the entry's secret slots off the shared describe mirror.
+ const settings = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-settings/lib/client.js'), 'utf8')
+ assert.match(settings, /super\(ctx, "configForms"\)/, 'configForms must be the settings domain service')
+ },
+)
+
+test(
+ 'the installed plugin-manager declares plugins.bundle.config as a keyed child of the Plugins page',
+ { skip: harnessPresent ? false : 'no global dsh installation to read the plugin manager from' },
+ () => {
+ const manager = readFileSync(join(DSH_PACKAGES, 'dsh-client-ui-plugin-manager/lib/client.js'), 'utf8')
+ assert.match(manager, /"plugins\.bundle\.config": \{\s*kind: "keyed"/)
+ // …and dispatches it by the bundle's package name.
+ assert.match(manager, /renderSlot\("plugins\.bundle\.config", \{ view: "page" \}, \{ entryKey: pkg\.name \}\)/)
+ },
+)
diff --git a/test/plugin.test.js b/test/plugin.test.js
index d3a11de..bd30e2f 100644
--- a/test/plugin.test.js
+++ b/test/plugin.test.js
@@ -9,7 +9,7 @@
import assert from 'node:assert/strict'
import { exec } from 'node:child_process'
-import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
@@ -39,7 +39,16 @@ const FULL_CONFIG = {
maxIndexUnits: 5000,
}
-/** A shell executor that really runs the command. */
+/**
+ * A shell executor shaped like `ShellExecutor` in dsh 0.1.7-rc.2: `resolve`,
+ * then `execute(spec)` returning a process handle whose `result()` is the
+ * foreground projection.
+ *
+ * There is deliberately no `run(spec)`: that single-step method was removed from
+ * the seam, and a plugin that still calls it fails with
+ * `shell.run is not a function` the first time it needs a command. Keeping the
+ * mock honest about the shape is the point — this file is the contract test.
+ */
const shell = {
sandboxMode: undefined,
resolve: (request) => ({
@@ -48,8 +57,8 @@ const shell = {
stdoutMaxBytes: 10_000_000,
...request,
}),
- run(spec) {
- return new Promise((resolve) => {
+ async execute(spec) {
+ const result = await new Promise((resolve) => {
exec(
spec.command,
{ cwd: spec.workdir, env: { ...process.env, ...(spec.env ?? {}) }, timeout: spec.timeoutMs, maxBuffer: 64 * 1024 * 1024 },
@@ -66,12 +75,27 @@ const shell = {
},
)
})
+ return { ...result, result: async () => result }
},
start() {
throw new Error('background process start is not used by these tests')
},
}
+/**
+ * A stub slopo executable.
+ *
+ * Discovery itself is `test/slopo.test.js`'s business. Here the tool must never
+ * reach `uv tool install`: that is a network round trip, it makes the suite
+ * slow and flaky, and a canned runner would answer it with success without
+ * installing anything — leaving the plugin to report SLOPO_INSTALL_MISSING.
+ * Pointing `binary` at a file that exists takes the "already installed" branch,
+ * which is the state every one of these tests means to exercise. A test that
+ * wants the missing-executable path passes `binary: ''` and `autoInstall: false`.
+ */
+const stubSlopo = join(mkdtempSync(join(tmpdir(), 'dsh-slopo-bin-')), 'slopo')
+writeFileSync(stubSlopo, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
+
/**
* Build a context exposing exactly the services the plugin consumes.
*
@@ -79,7 +103,7 @@ const shell = {
* so the assertions can see a result rather than a job handle.
*/
function harness(overrides = {}, { withSettings = true, withJobs = false, withShell = true, sandboxMode, sandboxPolicyResolver, shellRun } = {}) {
- const config = { ...FULL_CONFIG, ...overrides }
+ const config = { ...FULL_CONFIG, binary: stubSlopo, ...overrides }
// A per-harness shell, so a test can vary sandboxMode and every resolved
// request is recorded for assertion.
const scopedShell = {
@@ -90,19 +114,28 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
return { workdir: process.cwd(), timeoutMs: 600_000, stdoutMaxBytes: 10_000_000, ...request }
},
// A canned runner lets a test drive the index gate without a real slopo.
- ...(shellRun === undefined ? {} : { run: shellRun }),
+ // It replaces only the low-level run, so the `execute().result()` frame
+ // stays exactly the shape the real seam has.
+ async execute(spec) {
+ state.shellExecutions += 1
+ const result = shellRun === undefined ? await shell.execute(spec).then((handle) => handle.result()) : await shellRun(spec)
+ return { ...result, result: async () => result }
+ },
}
const state = {
tools: new Map(),
updates: [],
provider: undefined,
- namespace: undefined,
+ pagePolicy: undefined,
+ settingsEffects: 0,
shell: scopedShell,
shellRequests: [],
+ shellExecutions: 0,
jobs: undefined,
}
const ctx = {
+ fiber: { uid: 'test-fiber' },
logger: () => ({ info() {}, warn() {}, debug() {}, error() {} }),
get(name) {
if (name === 'shell') return withShell ? scopedShell : undefined
@@ -112,12 +145,22 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
},
inject(deps, callback) {
if (!withSettings || !deps.includes('settings')) return
+ // The child context `ctx.inject` hands back: its own effect scope plus
+ // the settings service. 0.1.7-rc.2 has no `installSection` — an entry's
+ // exported Config IS the form — so the only things a plugin does here are
+ // claim its page and write through `update`.
callback({
+ effect(effect) {
+ state.settingsEffects += 1
+ return effect()
+ },
settings: {
- installSection(owner, namespace, schema, entry, hooks) {
- state.namespace = namespace
- state.schema = schema
- hooks.setSource(() => config)
+ configure(presentation, owner) {
+ state.pagePolicy = presentation
+ state.pagePolicyOwner = owner
+ return () => {
+ state.pagePolicy = undefined
+ }
},
async update(namespace, patch) {
state.updates.push({ namespace, patch })
@@ -142,7 +185,7 @@ function harness(overrides = {}, { withSettings = true, withJobs = false, withSh
return { config, state }
}
-const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { session: { header: { cwd } } } })
+const execContext = (cwd) => ({ signal: new AbortController().signal, agent: { id: 'session-1', session: { header: { cwd } } } })
test('Config is a schemastery schema, which is what cordis requires', () => {
assert.equal(typeof Config, 'function')
@@ -192,9 +235,50 @@ test('every skill tells the agent to call the tool rather than a shell command',
}
})
-test('the settings namespace is registered under a grammar-valid name', () => {
+test('the plugin claims its own settings page instead of a generated one', () => {
+ // `auto: false` says "this entry ships a client page" — client/client.js
+ // registers one onto the Plugins page. The owner must be the plugin's own
+ // fiber, not the settings service's.
const { state } = harness()
- assert.match(state.namespace, /^[a-z][a-z0-9-]*$/)
+ assert.deepEqual(state.pagePolicy, { auto: false })
+ assert.equal(state.pagePolicyOwner?.uid, 'test-fiber', 'the policy belongs to the plugin instance')
+ assert.equal(state.settingsEffects, 1, 'the policy is registered as an effect, so a reload drops it')
+})
+
+test('the namespace is the Loader entry id the bundle patch declares', async () => {
+ const { state } = harness()
+ await state.tools.get('slopo').execute({ action: 'configure', config: { embeddingModel: 'x' } }, execContext('/tmp'))
+ assert.equal(state.updates[0].namespace, 'dsh-slopo')
+})
+
+test('the host half no longer calls the removed installSection API', () => {
+ // 0.1.7-rc.2 deleted `ctx.settings.installSection`; a plugin that still calls
+ // it loses its whole settings surface, silently, behind a try/catch.
+ const source = readFileSync(new URL('../index.js', import.meta.url), 'utf8')
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+ assert.doesNotMatch(code, /installSection/)
+})
+
+test('every Config field is volatile, which is what puts it on the settings page', () => {
+ // The framework's form projection keeps only fields under a volatile
+ // ancestor, and an entry whose form is empty is dropped from
+ // `settings.describe` — so an unmarked field is invisible to every UI.
+ // The walk is over the live schema: `Config.toJSON()` is a reference
+ // envelope for the wire, not a tree.
+ const fields = Object.entries(Config.dict ?? {})
+ assert.ok(fields.length > 0, 'the schema must declare fields')
+ for (const [key, node] of fields) {
+ assert.equal(node.meta?.volatile, true, `${key} must be marked volatile()`)
+ }
+})
+
+test('the embedding API key is a declared secret, so the settings wire never carries it', () => {
+ assert.equal(Config.dict.embeddingApiKey.meta?.role, 'secret')
+ // …and no other field claims to be one, because the browser cannot read a
+ // secret back and every other field is one the card must display.
+ for (const [key, node] of Object.entries(Config.dict)) {
+ if (key !== 'embeddingApiKey') assert.notEqual(node.meta?.role, 'secret', `${key} must not be secret`)
+ }
})
test('status reports the missing settings instead of pretending to be ready', async () => {
@@ -264,22 +348,35 @@ test('review refuses to run before the workspace is configured', async () => {
})
test('review writes a config the plugin owns and passes the key only in the environment', async () => {
- const { state } = harness({
- embeddingModel: 'ollama/example:latest',
- embeddingDimensions: 768,
- embeddingApiBase: 'http://127.0.0.1:11434',
- embeddingApiKey: 'super-secret',
- })
+ // Hermetic on purpose: `binary` points at a stub that exists, so the plugin
+ // takes the "already installed" branch instead of running `uv tool install`
+ // over the network, and the workspace is a real directory so the shell seam
+ // has somewhere to run. The canned runner answers both commands.
+ const workspace = mkdtempSync(join(tmpdir(), 'dsh-slopo-ws-'))
+ const binary = join(workspace, 'slopo')
+ writeFileSync(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
+ const ran = []
+ const { state } = harness(
+ {
+ binary,
+ embeddingModel: 'ollama/example:latest',
+ embeddingDimensions: 768,
+ embeddingApiBase: 'http://127.0.0.1:11434',
+ embeddingApiKey: 'super-secret',
+ },
+ { shellRun: cannedShell(ran, { indexOutput: 'Indexed 12 code units from 3 files.' }) },
+ )
const tool = state.tools.get('slopo')
- const value = await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz'))
- // It will fail — there is no git repo there — but the failure must be a
- // reported one, and the config must have been materialised first.
+ const value = await tool.execute({ action: 'review' }, execContext(workspace))
assert.equal(value.kind, 'foreground')
assert.ok(existsSync(value.configPath), `expected config at ${value.configPath}`)
- const { readFileSync } = await import('node:fs')
const written = readFileSync(value.configPath, 'utf8')
assert.match(written, /embedding_model: "ollama\/example:latest"/)
assert.doesNotMatch(written, /super-secret/)
+ // The key rides the environment of the run, not the file.
+ const executions = state.shellRequests.filter((request) => /agent-review/.test(request.command))
+ assert.equal(executions.length, 1)
+ assert.equal(executions[0].env.SLOPO_EMBEDDING_API_KEY, 'super-secret')
})
test('only the warm-up backgrounds by default; the query actions return directly', async () => {
@@ -321,6 +418,37 @@ test('only the warm-up backgrounds by default; the query actions return directly
assert.equal(started.length, 2)
})
+test('a background job is owned by the SESSION ID and settles a JobOutcome', async () => {
+ // Two things the 0.1.7-rc.2 job contract requires and the old code got wrong:
+ // `owner` is a session id (the registry looks the agent up itself, and a job
+ // owned by the wrong object can be neither collected nor cancelled), and the
+ // producer's `done` resolves a JobOutcome whose payload field is `result` —
+ // `output` is the ring's word, and a job that reports to `output` settles
+ // with nothing for job_output to hand back.
+ const started = []
+ const ran = []
+ const { state } = harness(
+ { embeddingModel: 'ollama/example:latest', embeddingDimensions: 768 },
+ { withJobs: true, shellRun: cannedShell(ran, { indexOutput: 'Indexed 5 code units from 1 files.' }) },
+ )
+ state.jobs = {
+ start(spec) {
+ started.push(spec)
+ return 'slopo-1'
+ },
+ }
+ const value = await state.tools.get('slopo').execute({ action: 'prepare' }, execContext('/tmp'))
+ assert.equal(value.kind, 'background')
+ assert.equal(started[0].owner, 'session-1', 'ownership is fenced by session id')
+
+ const hooks = started[0].run()
+ assert.equal(typeof hooks.cancel, 'function')
+ const outcome = await hooks.done
+ assert.equal(outcome.status, 'completed')
+ assert.equal(outcome.detail, 'exit code: 0')
+ assert.equal(typeof outcome.result, 'string', 'the report rides `result`, not `output`')
+})
+
test(
'review runs the real slopo CLI end to end against a real repository',
{ skip: process.env.DSH_SLOPO_E2E !== '1' || findSlopo(testHome) === undefined },
@@ -382,14 +510,19 @@ test('the sandbox policy is resolved for the CALLING SESSION, not the deployment
},
)
const tool = state.tools.get('slopo')
- await tool.execute({ action: 'review' }, execContext('/tmp/nonexistent-repo-xyz'))
+ await tool.execute({ action: 'review' }, execContext('/tmp'))
assert.equal(seen.length, 1, 'the policy must be resolved exactly once')
assert.ok(seen[0].session !== undefined, 'the calling session must travel with the policy')
- assert.equal(state.shellRequests.length, 1)
- assert.deepEqual(state.shellRequests[0].sandboxPolicy, {
- mode: 'danger-full-access',
- workspaceRoot: '/some/workspace',
- })
+ // Every command the run issued carries it — not just the first. The count is
+ // not the assertion: how many commands a review issues depends on whether the
+ // index gate let it through.
+ assert.ok(state.shellRequests.length >= 1, 'the run must reach the shell')
+ for (const request of state.shellRequests) {
+ assert.deepEqual(request.sandboxPolicy, {
+ mode: 'danger-full-access',
+ workspaceRoot: '/some/workspace',
+ })
+ }
})
test('a confining executor with no policy service is refused rather than guessed at', async () => {