dsh-facet
Scope an agent session to one facet of a system — the folder the conversation was started in, plus whatever you explicitly grant it.
A DeepSeek Harness bundle: one host row (the policy) and one agent preset (the enforcement).
Why this exists
A multi-faceted system is built from modules that look alike. In the project this was written against there are fourteen controllers — the same idioms, the same naming, the same shape — and a conversation about one of them that can see all fourteen spends its first several steps working out which one it is looking at. That is a quality problem before it is a cost problem, and the cost is real too: in a long session the dominant expense is re-reading accumulated history, and most of that history is file content the agent did not need.
So this narrows the world to one module rather than trying to steer through all
of them. It is the same idea as the focus argument in
dsh-recall — which confines a
search to one module — applied to the whole agent: what it may read, where it
may search, and what its prompt says it is.
What it does
Three things, and they are deliberately in three different places:
| where | why there | |
|---|---|---|
| the rule ("work inside the facet; ask to go outside") | system prompt | invariant for the session, so it does not invalidate the prompt cache |
| the binding (which folder, what is granted) | runtime-context snapshot | it changes when you grant something, and a changed system prompt costs a full-context cache miss |
| the guard | tools/pre-execute |
the part that does not depend on the model agreeing |
The scope is not configured — it is where you already are. A session's
working directory is recorded immutably when the session is created, so starting
a conversation in src/controllers/room/occupancy makes that controller the
facet, and starting one at the project root simply leaves the session unscoped
(which is the honest answer for a conversation that is genuinely about
everything).
Going outside is a question, not a refusal. In that same project 41.6% of the code — shared types, services, infrastructure — sits outside every controller. A wall that cannot be opened would not confine the agent, it would only make it wrong, and an agent that cannot reach a shared type does not stop needing it. So an out-of-scope file call is put to you, and saying yes records the grant for the rest of the conversation.
A tool that names no path is judged on the path it would use. grep, glob
and recall all default to a directory when the model omits one — the session
directory for the first two, a marker-chosen one for the third — and on a session
whose facet is narrower than its directory, that default reads outside the facet.
An absent argument is not an absent path, so those three are judged against the
default they would have searched, and the refusal says which argument to add.
lib/scope.js holds the list; a tool with no path argument at all is still left
alone, because it has no default that could leave.
The facet tool
| action | effect |
|---|---|
status |
the root, the mode, the standing grants and this session's grants |
add |
asks you to approve an extra path, then records it for this session |
remove |
drops a session grant |
reset |
returns to the session's own folder |
add is the only one that needs approval. It is also asked in deny mode,
because otherwise there would be no way to grant anything at all.
Install
From the repository, following releases:
dsh plugin --profile web add "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
dsh --profile web --dump-config | grep preset-facet # expect the row
Or from a checkout, while working on the plugin itself:
dsh plugin --profile web add "/path/to/dsh-facet"
The #semver:^0.1.0 fragment is the difference between an install that can be
updated and one that cannot. Leave it off and pnpm resolves the default branch to
a commit and records that commit in pnpm-lock.yaml — dsh plugin update
then has no version to move to, because there is nothing to move between. With
the fragment, pnpm resolves against the repository's tags, which the release
workflow creates on every push to master. The fragment needs a tag to resolve
against, so it only works once the first release has been published.
Either way the command is pnpm add run inside the profile, and it registers the
bundle in dsh.profile.bundles for you. A git-hosted plugin that builds on
install can be blocked by pnpm's build policy; add the key pnpm asks for under
allowBuilds in the profile's pnpm-workspace.yaml. This bundle ships sources
with no build step, so it does not trip that.
The bundle contributes two layers:
cordis.patch.yml— the host rowfacet-policy, which owns the mode and the standing grants and publishes them asctx.facetPolicy.presets/facet.patch.yml— the agent presetfacet, which is the shippedstandardcomposition plus the guard and a configured compaction row.
Then pick Facet as the preset for a session — in the Web UI that is the preset picker in the new-session surface, or Settings → General → Agent preset to make it the default for new sessions. The preset is fixed once a session's 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:
- id: facet-policy
name: dsh-facet
config:
mode: ask # ask | deny | off
extraRoots: [] # optional: a seed of paths always in scope, every session
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
callback. The rule is not: it was registered at load as a plain string, and
nothing unregisters it, so the prompt would go on stating a rule that nothing
enforces. An off deployment is therefore settled at load — set it in the
patch. ask and deny swap freely in both directions.
sectionOrder and contextOrder are deliberately absent from the card. They
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.
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.
A session spawned or forked from a scoped one inherits its root. Your declaration is keyed by session id, and a subagent or a fork has none of its own, so without this its facet would be its own working directory — one level up, and a way to delegate around the scope rather than work inside it. The inherited root already includes the parent's own narrowing, so delegating is not a way back out of a focus the agent set for itself either, and it is recorded per level, so it holds at any depth of delegation. It applies only where you made no statement: a declaration for the child session is the more specific answer and stands.
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.
Both controls are a directory listing, not a text box: the panel's vocabulary
is paths relative to the session folder, and "which folder is ../shared" is the
one question free text cannot answer. Pressing it opens a second dialog over the
panel, shaped like the host's own workspace browser — one level of children, a
breadcrumb trail, a hidden-entries toggle, and a file-type glyph per row. A pick
is stored in the form that keeps its meaning: /work/root/src/shared becomes
src/shared when the session started in /work/root.
The listing is driven by two Host listers, because they answer different questions and the extras need both:
| lister | returns | reach |
|---|---|---|
uiWorkspace.listDirectory |
folders | anywhere on the machine |
remote.workspaceFiles.list |
files and folders | the session's workspace only |
The extras picker takes the file listing while the level is inside the session folder, which is how a file is chosen by clicking its name instead of typing it — the same listing behind the sidebar's own file tree, applied one level at a time. Above the session folder the Host confines it, and the level falls back to folders and says so in the dialog: a file outside the workspace is still added by typing its path, which is why the action reads Add path or file and its field Path or file. The focus picker never offers files at all — a focus is a folder, so a file row there would offer something that cannot be chosen.
A folder row is a way down; a file row is a thing to take. Clicking a file fills the path field and marks the row, and the footer's action then adds it in declared form; pressing Enter on a name the level already lists is not an error, because the path is exactly what this dialog is for.
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
presets/facet.patch.yml also configures compaction-basic, because a scoped
session and a budgeted context are two halves of one idea — scoping reduces
context at the source, compaction is the ceiling:
thresholdRatio: 0.35 # a 350k ceiling, not the shipped 800k
retainTokens: 80000 # an absolute working set, not 16% of a 1M window
maxTokens: 16384 # the summarisation cap, stated rather than inherited
The shipped ratio is 0.80 of a 1,000,000-token window, i.e. 800k. Measured
across this deployment's 40 recorded sessions, the deepest ever reached 787,668
pressure tokens — the trigger had never once fired, so every token below it
was re-read on every subsequent step. maxTokens is worth stating explicitly:
its default is not 8192 but headroomTokens, 65536.
If you want that budget on every session rather than only scoped ones,
presets/standard-compaction.patch.yml is an opt-in overlay that applies the
same row to the shipped standard preset. It is not in the bundle's default
patch list, and the header of that file explains why: a preset declaration
replaces the whole plugin list rather than merging into it, so the file is a
frozen copy of standard that will not follow the harness when standard
changes.
What it does not cover
Honest boundaries, because each one is a decision rather than an oversight:
bashis not guarded. A shell command cannot be judged reliably from argv, so the facet is enforced on the file tools andbashremains a legitimate way out. The prompt tells the model this rather than pretending otherwise. It is not a theoretical hole: the first deployment to run this had an agent scoped to one module use a shell command to parse a config file outside its facet, having been told in the same prompt not to. A prompt is not a guard, which is what the rest of this plugin is for.- Tools that name a path in a shape the table cannot read are not guessed at —
including MCP tools. See
PATH_ARGUMENTSinlib/scope.js; it is a list of tools whose path argument is a plain top-level string. - This guards the tool pipeline, not the filesystem. There is no read seam in
the harness —
dsh-fs-sandboxstates plainly that "reads pass through untouched: every mode permits reading" — so anything that reads throughctx.fswithout going through tool dispatch (another plugin, the HTTP workspace-files endpoint) is unaffected. Fencing all readers would mean replacingctx.fswith a stricterFileSystemsubclass. - A grant is not persisted, so it does not survive a process restart.
- Sessions at the project root are not scoped at all, which is deliberate.
What has been verified, and what has not
Verified:
- 93 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/roomdoes not contain/a/roommate), that a malformed tool call never makes the guard throw, that a livemodeedit decides the next call without the guard being remounted, that an agent narrowing the user has since narrowed past is dropped rather than honoured, thatresetlands on the declared scope rather than the session directory, that a search naming no path is refused when the directory it defaults to is outside the facet, that a delegated session inherits its parent's root at any depth and is not a way back out of a self-narrowing, that both listings normalise into one picker level and that a file listing the Host confines still leaves a browsable one, and that everyfacetresult 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-configshowsfacet-policy,preset-facet, thefacet-guardrow and the compaction config, withpreset-standarduntouched. The optionalstandard-compactionoverlay was checked the same way.
Verified live. The bundle is installed in a web profile and composing
sessions: the runtime binding is in the running session's context, the crosshair
panel writes the declaration, and the guard decides the calls it names. That is
also where the bash boundary above was confirmed the hard way. The first thing
to check after any change is still that preset-facet is not broken — a preset
whose child row fails to resolve still appears on the roster, with its
diagnostic, and cannot compose a session:
dsh --profile web --dump-config | grep -A 3 preset-facet
Releases
.gitea/workflows/release.yml runs on every push to master: it works out the
range since the last tag, bumps package.json, prepends a CHANGELOG.md
section, commits and tags vX.Y.Z, then creates the Gitea release with the
commit subjects as the body.
The bug it fixes is not tidiness. Installed as a bare git+https://…/repo.git,
a plugin is pinned to a commit in the profile's lockfile, so with no tags
there is nothing for dsh plugin update to move to. Point the profile at
#semver:^0.1.0 and the updates follow releases instead:
"dsh-facet": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
The bump is read from the commit subjects being released:
| in a commit subject | effect |
|---|---|
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, so the first release matches the code.
The logic lives in .gitea/scripts/release.sh so it runs without a runner:
npm run test:release exercises it — an empty range, a repository with no tags
yet, 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.
Development
node scripts/dev-links.mjs # link the harness's cordis/schemastery/dsh-tools
npm test
dev-links.mjs links rather than installs those three packages, and for
@deepseek-ai/cordis that is a correctness requirement rather than tidiness: a
second copy of Cordis means a second Context class, two service registries in
one process, and a plugin whose services are invisible to the harness that loaded
it.
Layout
index.js host row: the policy, published as ctx.facetPolicy
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/ 93 tests
client/client.js is a browser half rather than a module the host imports, and
it is not optional decoration. The Plugins page does not build a card from a
Config schema — it draws only what browser halves register into the slots it
declares. volatile() on the host schema makes the namespace servable
(settings.describe() drops an entry with none, and the card registers only
while the namespace is served), but a schema alone puts nothing on the page.
The two keys in that file are deliberately different strings, and that is the
easy mistake to make here: the settings document is filed under the profile
entry id (facet-policy, the row's id in cordis.patch.yml) while the
card is keyed by the package name (dsh-facet). Every sibling plugin
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.
The listings reach the panel through ctx.get rather than through an injection —
uiWorkspace for folders and remote.workspaceFiles for files — and each
available() is asked on every render. Both are deliberate: ctx.inject would
make the whole context button conditional on services the plugin does not own, and
the panel is still the only way to declare scope in a deployment with no directory
browser — it would simply have no listing to offer, which is what it says instead.
The services are resolved at the call because the renderer memoizes a
registration's inject result, so a boolean read there would freeze at whatever the
first render saw. Both packages are named in this bundle's dsh.client.inject, so
a profile that installs this one gets the services its dialog asks for.
The two dialogs are wider than the primitive's 380px card, which is fixed in the
primitives' own stylesheet. client/client.js installs one small stylesheet of
its own — the widths and a row hover — and passes each card a class through
Modal's className. Every selector in it doubles the class it names, so it
outranks the single hashed class the primitive applies whatever order the two
stylesheets land in; there is no !important and nothing reaching into a class
name this bundle does not own. The panel's body is a two-column grid that
auto-fit collapses to one on a narrow card, and each dialog scrolls a bounded
body rather than growing past the viewport — the panel used to be a tall single
column of prose on a 380px card, which is what it looked like.
Also in this repository
facet-preset.md— the design note this was built from, including the harness mechanisms it rests on.compaction-tuning.md— the investigation into why compaction had never fired. Its measurements reproduce exactly; note that its "The change" section prescribes$DSH_HOME/.agent-presets/, a directory nothing reads any more on the installed harness. Presets are bundle patch declarations now, which is whatpresets/facet.patch.ymlis.