feat: set a conversation's scope from the composer, where the agent cannot reach
Release / release (push) Successful in 6s

The per-session scope the host half now reads had no writer. This is it: a
crosshair on the composer tool row, present only in sessions running the facet
preset, opening a panel that sets the focus and the paths allowed outside it.

The gate is the session's own `agentPreset` projection rather than anything
inferred from the URL or from the plugin being installed, so a control for a
plugin that is not running never appears.

It writes through the settings document — the same one the plugin card edits —
because `settings.mutate` accepts a path under a volatile node and refuses
anything else. That is what makes the split real rather than aspirational: the
agent has no write path to `scopes`, and the panel's write is the only one that
can widen a session.

The panel also carries the mode details and the usage notes, which the preset
picker's guide dialog cannot show for a preset no core package ships — its
guide table holds four shipped ids and is looked up only for built-ins.

Also adds `@deepseek-ai/dsh-client-ui-conversation` to the client half's inject
list: registering into `conversation.input.left` is a dependency on the package
that declares it, and `slots.inject` waits for a slot rather than creating one,
so naming another package's slot without loading it first registers nothing —
silently, because nothing errors.
This commit is contained in:
NotExpectedYet committed 2026-09-27 06:15:53 +01:00
1 parent d9caf4eeec
commit 266e90aa2d
4 files changed
+589 -37

No files matched your search

+54 -19
View File
@@ -103,23 +103,22 @@ first turn has run, so choose it when you start the conversation.
### Configure it in your profile, not in the package
The host row's config is where this deployment's facts belong — the profile's own
`cordis.patch.yml`:
The host row's config is where this *deployment's* facts belong — the profile's
own `cordis.patch.yml`:
```yaml
- id: facet-policy
name: dsh-facet
config:
mode: ask # ask | deny | off
extraRoots: # always in scope, in every session
- ../../shared # relative to each session's own directory
extraRoots: [] # optional: a seed of paths always in scope, every session
```
Both fields are also on the plugin's card under **Plugins** in the Web UI, which
is the same layer reached a different way: a settings write rewrites this entry in
the profile patch rather than storing anything new. An edit there is live — the
guard reads `mode` and `extraRoots` on every call, so a change decides the next
file call without a restart.
Both are also on the plugin's card under **Plugins** in the Web UI, which is the
same layer reached a different way: a settings write rewrites this entry in the
profile patch rather than storing anything new. An edit there is live — the guard
reads `mode` and `extraRoots` on every call, so a change decides the next file
call without a restart.
The one exception is `off`. Choosing it while a session is running stops the guard
immediately and empties the binding on the next request, because the binding is a
@@ -132,11 +131,37 @@ patch. `ask` and `deny` swap freely in both directions.
place the rule and the binding among the other prompt sections, which is decided
once at load, and no section is re-ordered without a remount.
`extraRoots` is for code a module legitimately always depends on. Anything you
grant by hand is per-conversation and is never persisted — "this session may
also see `src/shared`" is a statement about one conversation, and writing it down
would quietly widen every future one, which is the failure this plugin exists to
prevent.
### Per-session scope, and who gets to decide it
Scope has two layers, and the split is the point:
| layer | what it is | who can write it |
|---|---|---|
| **the user's declaration** | `scopes[<session id>]` in this row's config — a `focus` and a list of `extras` | only the context panel, through the settings service |
| **the agent's** | a narrowing `focus` and any session grants, held in memory | the agent, and only ever to *reduce* |
The effective root is the agent's narrowing while it still sits inside your root,
and your root otherwise. So an agent that goes wrong cannot widen its own scope,
and `facet action=reset` returns it to what you declared rather than to the
session directory — landing on the directory would have made `reset` a widening.
You set the declaration from the **crosshair button on the composer tool row**,
which appears only in sessions running the `facet` preset. It shows the focus and
the allowed paths, adds and removes them, and carries the mode details and usage
notes that the preset picker's guide dialog cannot show for a preset no core
package ships. Writes go to the profile patch, so reopening a chat gives back the
scope it had.
Because the panel owns widening, `facet action=add` is the *other* path and the
narrower one: it is gated on an approval channel, and a deployment with none — the
`danger-full-access` permission preset sets `approval: never` — refuses it before
anyone is asked. In that deployment the panel is the only way to widen, which is
exactly what the binding tells the agent.
`extraRoots` is only a seed now. Before per-session scope it was the *only* way to
widen anything, which meant "this conversation needs the entity folder" had to be
written as a fact about every conversation in every workspace. It is for paths a
deployment genuinely always wants, and it is empty by default.
## The compaction row
@@ -188,13 +213,15 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified:
- 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
call never makes the guard throw, that a live `mode` edit decides the next call
without the guard being remounted, and that every `facet` result satisfies the
tool's own declared output schema.
without the guard being remounted, that an agent narrowing the user has since
narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, and that every `facet` result
satisfies the tool's own declared output schema.
- The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
@@ -264,13 +291,13 @@ it.
```
index.js host row: the policy, published as ctx.facetPolicy
client/client.js browser half: the settings card on the Plugins page
client/client.js browser half: the settings card, and the context panel
lib/policy.js the stateful half — scope resolution, grants
lib/scope.js the pure half — argument extraction, text, reasons
lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 53 tests
test/ 73 tests
```
`client/client.js` is a browser half rather than a module the host imports, and
@@ -287,6 +314,14 @@ entry id (`facet-policy`, the row's `id` in `cordis.patch.yml`) while the
happens to name them identically, so binding the form to the wrong one produces
a card that renders, saves, and writes nowhere. `test/client.test.js` pins both.
The same file carries the context panel, registered into
`conversation.input.left` — the composer tool row — and gated on the session's own
`agentPreset` projection, so it appears only where the facet is actually in force.
That gate is why `scopes` is `volatile()` in the host schema: `settings.mutate`
refuses a path that is not under a volatile node, and refusing is exactly right —
the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing.
## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,