7 Commits
Author SHA1 Message Date
gitea-actions a40040ec17 Release v0.3.1 [skip release] [skip actions] 2026-09-27 06:44:49 +00:00
NotExpectedYet 8ab3299694 fix: a delegated session inherits the facet it was spawned under
Release / release (push) Successful in 6s
The user's declaration is keyed by session id, and a session spawned or forked
from a scoped one has none of its own — so its facet was its own working
directory, one level above its parent's. Delegation was therefore a way around
the scope rather than work inside it: the parent could not read the file itself
and could have a subagent read it.

A session now inherits the root resolved for the session named by its
`parentSession` header, which the harness already records durably for both a
spawned child and a fork. The inherited root is the parent's *effective* root, so
it already includes any narrowing the parent made for itself: delegating is not
a way back out of a self-imposed focus either. Because each session records its
own root as it resolves a scope, and a scope is resolved for every model request
and every tool call, inheritance holds at any depth of delegation rather than for
one hop.

Two boundaries are deliberate. A declaration the user made *for the child* is a
more specific statement about that session and stands — inheritance only answers
the case where they made none. And a parent's recorded root outlives its session
while any child that inherited it is still running, because disposing a parent
must not quietly widen a subagent that is still working.
2026-09-27 07:43:03 +01:00
NotExpectedYet a9a25490db fix: judge a search that names no path against the directory it would use
The guard's rule was that a call naming no path is not judged, because a tool
that names no path cannot leave the facet. That is true of a tool with no path
argument and false of three that have one with a default: `grep` and `glob`
document theirs as "Defaults to the session workspace", and `recall` falls back
to a directory its own marker search picks. On any session whose facet is
narrower than its directory — every session whose focus was set from the panel
rather than by starting the conversation in the folder — a bare `grep` searches
outside the facet and returns what it finds.

An absent argument is not an absent path; it is the default one. The three tools
are now judged against the directory they would have searched, through the same
containment test that decides every other call, and the refusal is its own
sentence rather than the "that path is outside" one: the path at issue is one the
model never wrote, so the remedy is a second argument, not a grant.

A tool with no path argument at all is still left alone. `bash` is still the
documented way out, and the first deployment running this plugin confirmed it is
a real one: an agent scoped to one module used a shell command to parse a config
file outside its facet, having been told in the same prompt not to. That is what
this plugin is for, and what it cannot do is stated in the README rather than
implied.
2026-09-27 07:42:54 +01:00
NotExpectedYet b0902017fd fix: name the allowed-paths action for what it adds, a path or a file
"Add folder or path" promised one thing and offered another: the listing is
folders only, because the Host's browse primitive has no file rows, while the
extras legitimately name files — `../../CONTROLLER.md` is the example the panel
itself gives.

The action is "Add path or file", its field is "Path or file", and the hint says
which of the two the list can show and how the other one is added. Nothing about
the interaction moved; the copy was the part that was wrong.
2026-09-27 07:31:50 +01:00
gitea-actions 6b92656ed2 Release v0.3.0 [skip release] [skip actions] 2026-09-27 06:08:22 +00:00
NotExpectedYet 5d06829118 feat: pick the focus and the allowed paths from a directory listing
Release / release (push) Successful in 6s
Both scope controls were free-text boxes, and the vocabulary they asked the user
for is paths relative to the session folder. "Which folder is `../shared`" is the
one question a text box cannot answer, so the panel now shows the Host's own
directory: the `uiWorkspace.listDirectory` primitive the workspace picker's
browser drives, one level of child folders, a breadcrumb trail, and a
hidden-entries toggle. The path field stays editable inside the picker, because
the Host lists folders and an extra may legitimately name a file.

Picks are stored in the form that keeps their meaning. A folder at
`/work/root/src/shared` is declared `src/shared` when the session started in
`/work/root`, and `../shared` going up; only a path with nothing shared — another
drive, another tree — stays absolute. The Focus row shows that declared form,
with the resolved path in its title, which is what the agent will see.

`Set` is gone with the box it committed: the panel writes on pick, as every other
control here already did. `Clear` stays, because empty means the session folder —
the schema's own default rather than an override naming the same place.

The card was 380px wide and grew without bound, a single column of prose taller
than the viewport. It is 760px now, with a two-column grid that `auto-fit`
collapses to one on a narrow card, the reference prose folded into `<details>`,
and a scroll-bounded body. `Modal` sizes its own card from a hashed class in the
primitives' stylesheet, so this bundle installs one small stylesheet of its own
and outranks that rule with a doubled class selector — no `!important`, and
nothing reaching into a class name it does not own.

Two fixes came with it. The footer's "Deployment mode:" line read a slot prop
that never carried the mode, so it said "ask" in every deployment; it reads the
settings document now. And `uiWorkspace` is resolved with `ctx.get` at the call
rather than injected, because the panel is still the only way to declare scope in
a deployment with no directory browser: it keeps the panel and says what it
cannot do, instead of losing the button.
2026-09-27 07:05:47 +01:00
NotExpectedYet ef3df2d939 docs: commit each step as the work lands, not in one lump at the end
The subjects are the release notes, so a working tree is a queue of unwritten
sentences: a change left uncommitted until the end is a change with no note, and
a batch of unrelated changes cannot be split into honest subjects once the
reasoning behind each one is only in the transcript.

The second reason is the one that changes a version, so it is stated too: the
range picks its bump from whatever subjects it ends up with, and one `feat:`
batched in with a handful of fixes is a minor bump for all of them.

Also says outright that committing is local, because pushing to `master` is what
releases and that is a different decision.
2026-09-27 07:05:43 +01:00
11 changed files with 1509 additions and 160 deletions

No files matched your search

+18
View File
@@ -21,6 +21,24 @@ release notes. Write it for that reader:
`feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the `feat` has to be a word, so `Fix the feature flag leak` is a patch. Only the
subject line is read; the body is not. subject line is read; the body is not.
## Commit as the work lands
Commit each step as soon as it is finished rather than leaving it in the tree
until the end. Two reasons, and the second is the one that bites:
- An uncommitted change has no release note at all. The subject is the sentence a
user reads, so work that never becomes a commit never becomes a sentence.
- A tree carrying four unrelated changes cannot be split into four honest
subjects after the fact — the reasoning behind each one is only in the
transcript by then, and the range picks its bump from whatever subjects it
ends up with. One `feat:` batched in with a handful of fixes is a minor bump
for all of them.
Keep a subject to one change and the version follows from it.
A commit here is local. Pushing to `master` is what releases, so it stays a
separate, deliberate step.
## The release job owns the version and the changelog ## The release job owns the version and the changelog
`package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived `package.json`'s `version` and `CHANGELOG.md` are the job's to write, derived
+20
View File
@@ -2,6 +2,26 @@
Every release, newest first, generated from the commits in each release. Every release, newest first, generated from the commits in each release.
## v0.3.1 — 2026-09-27
### Fixed
- a delegated session inherits the facet it was spawned under
- judge a search that names no path against the directory it would use
- name the allowed-paths action for what it adds, a path or a file
## v0.3.0 — 2026-09-27
### Added
- pick the focus and the allowed paths from a directory listing
### Documented
- commit each step as the work lands, not in one lump at the end
## v0.2.0 — 2026-09-27 ## v0.2.0 — 2026-09-27
### Added ### Added
+68 -10
View File
@@ -48,6 +48,15 @@ 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 So an out-of-scope file call is put to you, and saying yes records the grant for
the rest of the conversation. 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 ### The `facet` tool
| action | effect | | action | effect |
@@ -145,6 +154,15 @@ 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 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. 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**, 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 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 the allowed paths, adds and removes them, and carries the mode details and usage
@@ -152,6 +170,18 @@ 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 package ships. Writes go to the profile patch, so reopening a chat gives back the
scope it had. 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 child folders,
a breadcrumb trail, a hidden-entries toggle — driven through
`uiWorkspace.listDirectory`, the same Host primitive that browser uses. 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 picker's own path field
stays editable, because the Host lists folders and an extra may legitimately name
a file — which is why that action reads **Add path or file** and its field **Path
or file**, rather than promising a folder the list would then fail to show.
Because the panel owns widening, `facet action=add` is the *other* path and the 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 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 `danger-full-access` permission preset sets `approval: never` — refuses it before
@@ -196,7 +226,10 @@ Honest boundaries, because each one is a decision rather than an oversight:
- **`bash` is not guarded.** A shell command cannot be judged reliably from - **`bash` is not guarded.** A shell command cannot be judged reliably from
argv, so the facet is enforced on the file tools and `bash` remains a argv, so the facet is enforced on the file tools and `bash` remains a
legitimate way out. The prompt tells the model this rather than pretending legitimate way out. The prompt tells the model this rather than pretending
otherwise. 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** — - **Tools that name a path in a shape the table cannot read are not guessed at** —
including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of
tools whose path argument is a plain top-level string. tools whose path argument is a plain top-level string.
@@ -213,26 +246,31 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified: Verified:
- 73 tests (`npm test`) over the scope algebra, the policy state, the guard, and - 88 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets 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 (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 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 call never makes the guard throw, that a live `mode` edit decides the next call
without the guard being remounted, that an agent narrowing the user has since 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 narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, and that every `facet` result declared scope rather than the session directory, that a search naming no path
satisfies the tool's own declared output schema. 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, 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 - The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
the `facet-guard` row and the compaction config, with `preset-standard` the `facet-guard` row and the compaction config, with `preset-standard`
untouched. The optional `standard-compaction` overlay was checked the same way. untouched. The optional `standard-compaction` overlay was checked the same way.
**Not yet verified: a live session.** The plugin has not been loaded by a running **Verified live.** The bundle is installed in a `web` profile and composing
harness, because that needs the bundle installed. The first thing to check after sessions: the runtime binding is in the running session's context, the crosshair
installing is that `preset-facet` is not `broken` — a preset whose child row panel writes the declaration, and the guard decides the calls it names. That is
fails to resolve still appears on the roster, with its diagnostic, and cannot also where the `bash` boundary above was confirmed the hard way. The first thing
compose a session: 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:
```sh ```sh
dsh --profile web --dump-config | grep -A 3 preset-facet dsh --profile web --dump-config | grep -A 3 preset-facet
@@ -297,7 +335,7 @@ lib/scope.js the pure half — argument extraction, text, reason
lib/guard.js preset row: rule, binding, tool, pre-execute guard lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 73 tests test/ 88 tests
``` ```
`client/client.js` is a browser half rather than a module the host imports, and `client/client.js` is a browser half rather than a module the host imports, and
@@ -322,6 +360,26 @@ 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 the panel is the only writer, and a plugin that could not serve the namespace
would be a control that silently stored nothing. would be a control that silently stored nothing.
The listing reaches the panel through `ctx.get('uiWorkspace')` rather than through
an injection, and the injection's `available()` is asked on every render. Both are
deliberate: `ctx.inject` would make the whole context button conditional on a
service 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 service is 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.
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 ## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, - [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+743 -118
View File
@@ -39,7 +39,14 @@ window.__ModuleLoader__.load({
const react = require('react') const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives') const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const { Modal, SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives const {
Modal,
SegmentedControl,
SettingsForm,
SettingsFormModel,
SettingsValueField,
IconFolderCloseRegular,
} = primitives
const h = react.createElement const h = react.createElement
@@ -326,6 +333,452 @@ window.__ModuleLoader__.load({
const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' } const bodyStyle = { margin: '0 0 14px', fontSize: '12.5px', lineHeight: '19px', color: 'var(--dsw-alias-label-secondary)' }
const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' } const listedStyle = { ...bodyStyle, margin: '4px 0 0', fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)' }
// ── the directory listing ────────────────────────────────────────────────
/** The classes that widen the two dialogs past the primitive's 380px card. */
const PANEL_CLASS = 'dsh-facet-panel'
const PICKER_CLASS = 'dsh-facet-picker'
const ROW_CLASS = 'dsh-facet-row'
/**
* The stylesheet this bundle installs, for the two things an inline style
* cannot reach.
*
* `Modal` sizes its own card and takes only a class name, and a `:hover`
* state has no inline spelling, so both live here. Every selector names a
* class of ours and doubles it: the primitive's card rule is a single hashed
* class, and a doubled unhashed one outranks it whatever order the two
* stylesheets land in. That is the whole reason for the doubling — no
* `!important`, and nothing reaching into a class name this bundle does not
* own.
*
* The widths are the fix for the card the panel used to be: 380px is
* comfortable for a sentence and cramped for a path listing.
*/
const STYLE_ID = 'dsh-facet'
const STYLE_CSS =
'.dsh-facet-panel.dsh-facet-panel{width:min(760px,100%)}' +
'.dsh-facet-picker.dsh-facet-picker{width:min(560px,100%)}' +
'.dsh-facet-row:enabled:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
'.dsh-facet-row:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}'
/**
* Install {@link STYLE_CSS} once per document.
*
* Guarded on `document` because the Node test harness loads this module with
* only `window` and `require` faked, and there is no DOM there to style.
*/
function installStyles() {
if (typeof document === 'undefined') return
if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return
const tag = document.createElement('style')
tag.dataset.plugin = 'dsh-facet'
tag.dataset.pluginCss = STYLE_ID
tag.textContent = STYLE_CSS
document.head.appendChild(tag)
}
installStyles()
/**
* Whether a declared path stands on its own instead of resolving against
* the session folder.
*
* Both separators, because a profile patch is written on one platform and
* read on whichever machine runs it.
*/
function isAbsolutePath(path) {
return typeof path === 'string' && (/^[\\/]/.test(path) || /^[A-Za-z]:[\\/]/.test(path))
}
/** One host path's segments, on either separator. */
function pathSegments(path) {
return String(path)
.split(/[\\/]+/)
.filter((segment) => segment !== '')
}
/**
* Say an absolute host path the way this panel declares paths: relative to
* the session folder where that still names it, absolute where it does not.
*
* Relative is not cosmetic. A declaration is resolved against the session
* directory at the point of use, so `../shared` keeps its meaning when the
* workspace moves and `/srv/old-workspace/shared` does not.
*
* @param {string} root - the session folder, absolute, or '' when unknown.
* @param {string} target - the path to express.
* @returns {string} the declared form; '' is the session folder itself.
*/
function relativeTo(root, target) {
const path = String(target ?? '')
if (typeof root !== 'string' || root === '' || path === '') return path
const base = pathSegments(root)
const full = pathSegments(path)
let common = 0
while (common < base.length && common < full.length && base[common] === full[common]) common += 1
// Nothing shared — another drive, or a path that is relative already.
// Absolute is the only honest spelling left.
if (common === 0) return path
const up = base.slice(common).map(() => '..')
return [...up, ...full.slice(common)].join('/')
}
/**
* Resolve a declared path against the session folder, for a listing call.
*
* The host's browse primitive lists absolute paths only, so a stored
* `focus` has to be joined before it can be opened. Joining is a string
* operation rather than a `path.resolve` because there is no `node:path` on
* this side of the wire and the host accepts either separator.
*
* @param {string} root - the session folder, absolute, or '' when unknown.
* @param {string} declared - a stored focus or extra.
* @returns {string} an absolute path where one can be built, else the input.
*/
function absoluteFrom(root, declared) {
const path = String(declared ?? '').trim()
if (path === '' || isAbsolutePath(path)) return path
if (typeof root !== 'string' || root === '') return path
return `${root.replace(/[\\/]+$/, '')}/${path.replace(/^[\\/]+/, '')}`
}
/**
* The operator-facing text for a failed browse call.
*
* The Host's own business message is preferred to the transport error,
* because a browse refusal is an answer — the folder is unreadable, the path
* is not absolute — and that sentence is the one worth reading.
*/
function browseFailure(error) {
const rpc = error !== null && typeof error === 'object' ? error.rpcError : undefined
if (rpc !== null && typeof rpc === 'object' && typeof rpc.message === 'string') return rpc.message
return error instanceof Error ? error.message : String(error)
}
/**
* The state behind the picker: the level on screen, the path being edited,
* and the last failure.
*
* A model rather than component state because a browse is asynchronous and
* supersedable: clicking down three folders fires three listings, and the
* first one landing last must not overwrite the third. Each request carries
* a sequence number and only the newest may commit. It also keeps the whole
* interaction testable without a DOM, which is the same reason the settings
* card has a controller.
*/
var DirectoryPickerModel = class {
/**
* @param {object} options
* @param {(path?: string, signal?: AbortSignal) => Promise<object>} options.list - the host listing call.
* @param {string} options.root - the session folder, absolute, or ''.
* @param {string} [options.start] - the directory to open first; the root by default.
*/
constructor(options) {
const root = typeof options.root === 'string' ? options.root : ''
this.list = options.list
this.listeners = new Set()
this.request = 0
this.state = {
listing: null,
loading: false,
error: '',
showHidden: false,
draft: typeof options.start === 'string' && options.start !== '' ? options.start : root,
}
this.open(this.state.draft)
}
/**
* @param {() => void} listener - woken on every commit.
* @returns {() => void} the unsubscribe.
*/
subscribe(listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
/** @returns {object} the current state, for `useSyncExternalStore`. */
getSnapshot() {
return this.state
}
/** @param {object} patch @returns {void} */
update(patch) {
this.state = { ...this.state, ...patch }
for (const listener of [...this.listeners]) listener()
}
/**
* List one directory, discarding any listing already in flight.
*
* An empty path asks the Host for its home directory, which is the right
* answer when the session folder is not known yet. A failure keeps the
* level that was on screen and the path as typed, because a listing can
* fail on a path this panel may still legitimately declare — a file rather
* than a folder is the ordinary case, not an error.
*
* @param {string} [path] - the absolute directory to list.
*/
open(path) {
const target = String(path ?? '').trim()
const request = (this.request += 1)
this.update({ loading: true, error: '', draft: target })
Promise.resolve()
.then(() => this.list(target === '' ? undefined : target))
.then(
(listing) => {
if (request !== this.request) return
this.update({ listing, loading: false, error: '', draft: String(listing?.path ?? target) })
},
(reason) => {
if (request !== this.request) return
this.update({ loading: false, error: browseFailure(reason) })
},
)
}
/** @param {string} text - the path field's own value. */
setDraft(text) {
this.update({ draft: String(text) })
}
/** @param {boolean} showHidden - whether host-flagged hidden folders are listed. */
setShowHidden(showHidden) {
this.update({ showHidden: showHidden === true })
}
}
/** Picker styling: the primitive's own control shapes, one size down. */
const pickerBodyStyle = { maxHeight: 'min(52vh, 360px)', overflowY: 'auto', paddingRight: '4px' }
const pickerLevelStyle = { display: 'flex', flexDirection: 'column', gap: '2px', marginTop: '4px' }
const pickerRowStyle = {
display: 'flex',
alignItems: 'center',
gap: '8px',
boxSizing: 'border-box',
width: '100%',
height: '30px',
padding: '0 8px',
border: 0,
borderRadius: 'var(--dsw-radius-sm)',
background: 'transparent',
color: 'var(--dsw-alias-label-primary)',
fontFamily: 'inherit',
fontSize: '13px',
textAlign: 'left',
cursor: 'pointer',
}
const pickerNameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }
const pickerGlyphStyle = { display: 'inline-flex', flex: 'none', color: 'var(--dsw-alias-label-secondary)' }
const crumbStyle = {
flex: 'none',
border: 0,
padding: '0 2px',
background: 'transparent',
color: 'var(--dsw-alias-label-tertiary)',
fontFamily: 'inherit',
fontSize: '12.5px',
cursor: 'pointer',
}
const pickerStatusStyle = {
margin: '6px 0 0',
fontSize: '12px',
lineHeight: '18px',
color: 'var(--dsw-alias-label-tertiary)',
}
const pickerErrorStyle = { ...pickerStatusStyle, color: 'var(--dsw-alias-state-error-primary)' }
const toggleStyle = {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '12.5px',
color: 'var(--dsw-alias-label-secondary)',
cursor: 'pointer',
}
/**
* The directory picker: one editable path, the ancestry above it, and one
* level of child folders.
*
* It drives no host call of its own — {@link DirectoryPickerModel} holds the
* listing and the path, and the panel builds one per opening so each visit
* starts from what the Host holds rather than from the last one's draft.
*
* @param {object} props - the model, the session folder, what is being picked, and the outcome calls.
*/
function DirectoryPicker(props) {
const { model, root, target, disabled, onClose, onPick } = props
const state = react.useSyncExternalStore(
(listener) => model.subscribe(listener),
() => model.getSnapshot(),
)
const listing = state.listing
const blocked = disabled === true
const typed = state.draft
const entries = listing === null ? [] : listing.entries.filter((entry) => state.showHidden || entry.hidden !== true)
// What the field currently means as a declaration. '' is the session
// folder: the focus's own default, and for an extra — already the root —
// nothing worth adding.
const declared = relativeTo(root, typed)
const rows = entries.map((entry) =>
h(
'button',
{
key: entry.path,
type: 'button',
className: ROW_CLASS,
style: pickerRowStyle,
title: entry.path,
disabled: blocked || state.loading,
// A click walks down rather than picking: the primary action is the
// path field's own value, so a folder is chosen by standing in it.
onClick: () => model.open(entry.path),
},
h('span', { style: pickerGlyphStyle }, h(IconFolderCloseRegular, { size: 14 })),
h('span', { style: pickerNameStyle }, entry.name),
),
)
const trail = []
for (const [index, crumb] of (listing === null ? [] : listing.crumbs).entries()) {
if (index > 0) trail.push(h('span', { key: `sep-${index}`, style: { color: 'var(--dsw-alias-label-caption)' } }, '/'))
trail.push(
h(
'button',
{
key: crumb.path,
type: 'button',
style: crumbStyle,
title: crumb.path,
disabled: blocked || state.loading,
onClick: () => model.open(crumb.path),
},
// A root crumb carries its whole path as its name; "Home" is the
// shorter label the host's own browser gives it, and the title
// attribute still names where it is.
crumb.path === listing.home ? 'Home' : crumb.name,
),
)
}
const status =
state.error !== ''
? h('p', { key: 'status', style: pickerErrorStyle }, state.error)
: state.loading
? h('p', { key: 'status', style: pickerStatusStyle }, 'Listing\u2026')
: listing === null
? h('p', { key: 'status', style: pickerStatusStyle }, 'Type a folder to start from.')
: entries.length === 0
? h('p', { key: 'status', style: pickerStatusStyle }, 'No folders in here.')
: listing.truncated === true
? h('p', { key: 'status', style: pickerStatusStyle }, 'Too many folders to list; only the beginning is shown.')
: null
return h(Modal, {
open: true,
onClose,
title: target === 'focus' ? 'Choose the focus folder' : 'Add a path or a file',
closeLabel: 'Close',
className: PICKER_CLASS,
// One scroll region around everything but the footer, so the card is
// bounded on a short viewport and the action stays reachable. The
// primitive's card is content-sized with `overflow: hidden`, so a fixed
// footer is the only way to keep it in view without reaching into the
// primitive's own class names.
children: h(
'div',
{ style: pickerBodyStyle },
h(
'div',
{ key: 'path' },
h('label', { style: labelStyle, htmlFor: 'facet-picker-path' }, target === 'focus' ? 'Folder' : 'Path or file'),
h('input', {
id: 'facet-picker-path',
type: 'text',
style: { ...controlStyle, width: '100%' },
value: typed,
disabled: blocked,
spellCheck: false,
// The Host lists folders, so this box is also how a file — or a
// folder the session will create — is named at all.
'data-modal-autofocus': true,
placeholder: root === '' ? absoluteFrom(root, 'src/shared') : 'relative/to/the/session/folder',
onChange: (event) => model.setDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
model.open(typed)
},
}),
h(
'p',
{ style: hintStyle },
target === 'focus'
? 'Relative to the session folder, or absolute. Enter lists whatever is there.'
: // The list holds folders only — the Host's browse primitive has no
// file rows — so the box is where a file is named, and saying so
// is the difference between a dead end and an instruction.
'Relative to the session folder, or absolute. The list holds folders; type a file\u2019s name to add the file itself.',
),
),
h(
'div',
{
key: 'trail',
style: {
display: 'flex',
alignItems: 'center',
gap: '2px',
minHeight: '22px',
marginTop: '12px',
overflowX: 'auto',
},
},
trail.length === 0 ? null : trail,
),
h('div', { key: 'level', style: pickerLevelStyle }, rows),
status,
),
footer: [
// Kept beside the action rather than under the list, the way the
// host's own browser does it: one control, always in view.
h(
'label',
{ key: 'hidden', style: toggleStyle },
h('input', {
type: 'checkbox',
checked: state.showHidden,
disabled: blocked,
onChange: (event) => model.setShowHidden(event.target.checked),
}),
'Show hidden folders',
),
h('span', { key: 'gap', style: { flex: 1 } }),
h('button', { key: 'cancel', type: 'button', style: quietButtonStyle, onClick: onClose }, 'Cancel'),
h(
'button',
{
key: 'pick',
type: 'button',
style: { ...quietButtonStyle, color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' },
// An extra that resolves to the session folder would add the root
// to a list of things beyond it; the focus may say exactly that,
// which is how it spells "the session folder itself".
disabled: blocked || typed.trim() === '' || (target === 'extra' && declared === ''),
// The declared form, not the absolute one: the session folder is
// where this conversation lives, and a path that says so keeps its
// meaning if the workspace moves.
onClick: () => onPick(declared),
},
target === 'focus' ? 'Set focus' : 'Add',
),
],
})
}
/** The crosshair that marks the button: a scope, not a setting. */ /** The crosshair that marks the button: a scope, not a setting. */
const CONTEXT_GLYPH = h( const CONTEXT_GLYPH = h(
'svg', 'svg',
@@ -339,6 +792,57 @@ window.__ModuleLoader__.load({
}), }),
) )
/** The panel's own scroll region: the card is wide, not tall. */
const panelBodyStyle = { maxHeight: 'min(62vh, 540px)', overflowY: 'auto', paddingRight: '4px' }
/**
* Two columns on a wide card, one on a narrow one. `auto-fit` is the whole
* responsive story: at 380px — the width this card used to be — the second
* track does not fit and every cell takes the full row.
*/
const panelGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '28px',
alignItems: 'start',
}
/** The focus, shown as the declared path rather than as an editable box. */
const focusPathStyle = {
flex: 1,
minWidth: 0,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
height: '32px',
padding: '0 10px',
border: '0.5px solid var(--dsw-alias-border-l4)',
borderRadius: 'var(--dsw-radius-md)',
background: 'var(--dsw-alias-bg-layer-3)',
fontFamily: 'var(--dsw-font-mono, ui-monospace, monospace)',
fontSize: '12.5px',
color: 'var(--dsw-alias-label-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const focusEmptyPathStyle = { ...focusPathStyle, fontFamily: 'inherit', color: 'var(--dsw-alias-label-tertiary)' }
/**
* One folded-away reference section.
*
* The guidance used to sit under the controls at full length, which made a
* panel that is mostly prose and filled the viewport. Folded, it is still
* there for the session that needs it and costs two lines otherwise.
*/
function disclosure(summary, content) {
return h(
'details',
{ style: { marginTop: '18px' } },
h('summary', { style: { ...sectionTitleStyle, cursor: 'pointer', margin: 0 } }, summary),
h('div', { style: { marginTop: '8px' } }, content),
)
}
/** /**
* The facet context panel: what this conversation may see, and who decides. * The facet context panel: what this conversation may see, and who decides.
* *
@@ -347,10 +851,18 @@ window.__ModuleLoader__.load({
* decides what is in scope" a property of the system rather than of the * decides what is in scope" a property of the system rather than of the
* agent's instructions. * agent's instructions.
* *
* @param {object} props - the bound form, the session, and the close command. * Both scope controls are the same directory listing rather than two text
* boxes. The panel's vocabulary is paths relative to the session folder, and
* "which folder is `../shared`" is exactly the question a free-text box
* cannot answer; the listing answers it from the Host's own directory rather
* than from a guess about what the path was meant to name. The path field is
* still editable inside the picker, because the Host lists folders and the
* extras may name a file.
*
* @param {object} props - the bound form, the session, its folder, the browse face, and the close command.
*/ */
function FacetContextPanel(props) { function FacetContextPanel(props) {
const { form, sessionId, mode, onClose } = props const { form, sessionId, mode, onClose, browse } = props
// `form` is the settings document for `facet-policy`, already a store: the // `form` is the settings document for `facet-policy`, already a store: the
// panel renders whatever the Host last accepted, including a write from // panel renders whatever the Host last accepted, including a write from
@@ -360,11 +872,26 @@ window.__ModuleLoader__.load({
() => form.getSnapshot(), () => form.getSnapshot(),
) )
const declared = declaredFor(snapshot, sessionId) const declared = declaredFor(snapshot, sessionId)
const [focusDraft, setFocusDraft] = react.useState(declared.focus) const [picker, setPicker] = react.useState(null)
const [extraDraft, setExtraDraft] = react.useState('')
const [failure, setFailure] = react.useState('') const [failure, setFailure] = react.useState('')
const disabled = snapshot.writable !== true const disabled = snapshot.writable !== true
// The session's own folder: what every relative path in this panel is
// relative to, and where a listing starts. Absent on a session whose
// summary has not arrived, in which case the Host's home directory stands
// in and every path is declared absolute.
const root = typeof props.cwd === 'string' ? props.cwd : ''
// Asked during render rather than once at registration: the service may
// activate after this bundle does, and a frozen `false` would hide the
// listing for the rest of the session.
const canBrowse = browse?.available?.() === true && !disabled
// The mode is a fact about the settings document, so it is read from the
// document rather than looked for among the slot's own props — which
// never carried it, leaving this line saying "ask" in every deployment.
const documentMode = snapshot.value?.mode
const shownMode = typeof mode === 'string' ? mode : typeof documentMode === 'string' ? documentMode : 'ask'
const write = (next) => { const write = (next) => {
writeDeclaration(form, sessionId, next).then( writeDeclaration(form, sessionId, next).then(
(ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'), (ok) => setFailure(ok ? '' : 'The deployment did not accept that change.'),
@@ -372,11 +899,36 @@ window.__ModuleLoader__.load({
) )
} }
// One model per opening, built at the click rather than at render, so
// closing and reopening the picker starts from what the Host holds.
const openPicker = (target) => {
if (!canBrowse) return
// The listing opens on the stored focus for the focus control and on the
// session folder otherwise. A path with nothing to resolve against — a
// relative declaration and an unknown session folder — is not a starting
// point at all, so an empty one asks the Host for its home instead.
const stored = target === 'focus' ? absoluteFrom(root, declared.focus) : root
setPicker({
target,
model: new DirectoryPickerModel({
list: browse.list,
root,
start: isAbsolutePath(stored) ? stored : '',
}),
})
}
const rows = declared.extras.map((path) => const rows = declared.extras.map((path) =>
h( h(
'li', 'li',
{ key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } }, { key: path, style: { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '6px' } },
h('code', { style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' } }, path), h(
'code',
// The declared form is what is stored; the title spells out the
// absolute path it resolves to, which is what the agent will see.
{ style: { ...listedStyle, margin: 0, flex: 1, wordBreak: 'break-all' }, title: absoluteFrom(root, path) },
path,
),
h( h(
'button', 'button',
{ {
@@ -390,130 +942,157 @@ window.__ModuleLoader__.load({
), ),
) )
const overlay =
picker === null
? null
: h(DirectoryPicker, {
model: picker.model,
root,
target: picker.target,
disabled,
onClose: () => setPicker(null),
onPick: (path) => {
setPicker(null)
// The picker already answers in declared form: an empty string
// is the session folder itself, which is `focus`'s own default
// rather than a stored override naming the same place.
if (picker.target === 'focus') write({ ...declared, focus: path })
else write({ ...declared, extras: [...declared.extras, path] })
},
})
return h( return h(
Modal, Modal,
{ open: true, onClose, title: CONTEXT_LABEL, closeLabel: 'Close', description: `Session ${sessionId}` }, {
open: true,
onClose,
title: CONTEXT_LABEL,
closeLabel: 'Close',
description: `Session ${sessionId}`,
className: PANEL_CLASS,
},
h( h(
'div', 'div',
null, { style: panelBodyStyle },
h('h3', { style: sectionTitleStyle }, 'What this conversation may see'), // Two columns, because the panel is now wide: the controls on the
h( // left where the eye starts, the reference prose on the right where it
'p', // reads at a sane line length. `auto-fit` collapses it back to one
{ style: bodyStyle }, // column on a narrow viewport, so nothing has to know the width.
'The agent works inside the focus folder. Anything it needs beyond that is listed below, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
),
h('label', { style: sectionTitleStyle, htmlFor: 'facet-focus' }, 'Focus'),
h( h(
'div', 'div',
{ style: { display: 'flex', gap: '8px' } }, { style: panelGridStyle },
h('input', {
id: 'facet-focus',
type: 'text',
style: { ...controlStyle, flex: 1 },
placeholder: 'the folder the session started in',
value: focusDraft,
disabled,
'data-modal-autofocus': true,
onChange: (event) => setFocusDraft(event.target.value),
onKeyDown: (event) => {
if (event.key !== 'Enter') return
event.preventDefault()
write({ ...declared, focus: focusDraft })
},
}),
h( h(
'button', 'div',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: focusDraft }) }, null,
'Set', h('h3', { style: sectionTitleStyle }, 'Focus'),
), h(
h( 'div',
'button', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
{ h(
type: 'button', 'span',
style: quietButtonStyle, { style: declared.focus === '' ? focusEmptyPathStyle : focusPathStyle, title: absoluteFrom(root, declared.focus) },
disabled, declared.focus === '' ? 'the session folder' : declared.focus,
onClick: () => { ),
setFocusDraft('') h(
write({ ...declared, focus: '' }) 'button',
}, { type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('focus') },
}, 'Browse\u2026',
'Clear', ),
), declared.focus === ''
), ? null
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute. Empty means the session folder itself.'), : h(
'button',
{ type: 'button', style: quietButtonStyle, disabled, onClick: () => write({ ...declared, focus: '' }) },
'Clear',
),
),
h(
'p',
{ style: hintStyle },
root === ''
? 'Relative to the session folder, or absolute. Empty means the session folder itself.'
: `Relative to the session folder (${root}), or absolute. Empty means the session folder itself.`,
),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'), h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Also allowed'),
declared.extras.length === 0 declared.extras.length === 0
? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.') ? h('p', { style: bodyStyle }, 'Nothing yet. Shared types, project docs, or a sibling folder go here.')
: h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows), : h('ul', { style: { listStyle: 'none', margin: 0, padding: 0 } }, rows),
h( h(
'div', 'div',
{ style: { display: 'flex', gap: '8px', marginTop: '10px' } }, { style: { marginTop: '10px' } },
h('input', { h(
id: 'facet-extra', 'button',
type: 'text', { type: 'button', style: quietButtonStyle, disabled: !canBrowse, onClick: () => openPicker('extra') },
style: { ...controlStyle, flex: 1 }, '+ Add path or file\u2026',
placeholder: 'src/shared, ../../CONTROLLER.md', ),
value: extraDraft, ),
disabled, h(
onChange: (event) => setExtraDraft(event.target.value), 'p',
onKeyDown: (event) => { { style: bodyStyle },
if (event.key !== 'Enter') return 'Relative to the session folder, or absolute — anything the session needs beyond the focus.',
event.preventDefault() ),
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
}),
h(
'button',
{
type: 'button',
style: quietButtonStyle,
disabled,
onClick: () => {
if (extraDraft.trim() === '') return
write({ ...declared, extras: [...declared.extras, extraDraft] })
setExtraDraft('')
},
},
'Add',
),
),
h('p', { style: bodyStyle }, 'Relative to the session folder, or absolute — anything the session needs beyond the focus.'),
failure === '' ? null : h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure), canBrowse
h( ? null
'p', : h(
{ style: { ...bodyStyle, marginTop: '18px' } }, 'p',
`Changes take effect on the next tool call. Deployment mode: ${mode ?? 'ask'}.`, { style: bodyStyle },
), 'This deployment has no directory browser, so paths cannot be picked here. Standing grants can still be set on the plugin card; a per-session declaration needs a browser.',
),
failure === ''
? null
: h('p', { style: { ...bodyStyle, color: 'var(--dsw-alias-state-error-primary)' } }, failure),
h(
'p',
{ style: { ...bodyStyle, marginTop: '18px' } },
`Changes take effect on the next tool call. Deployment mode: ${shownMode}.`,
),
),
h('h3', { style: { ...sectionTitleStyle, marginTop: '18px' } }, 'Mode details'),
h(
'p',
{ style: bodyStyle },
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
),
h('h3', { style: sectionTitleStyle }, 'How to use'),
h(
'ol',
{ style: { ...bodyStyle, paddingLeft: '18px' } },
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
h( h(
'li', 'div',
{ style: { marginBottom: '6px' } }, null,
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.', h('h3', { style: sectionTitleStyle }, 'What this conversation may see'),
h(
'p',
{ style: bodyStyle },
'The agent works inside the focus folder. Anything it needs beyond that is listed here, and the agent can narrow the focus on its own but can never widen it — only you can, here.',
),
disclosure(
'Mode details',
h(
'p',
{ style: bodyStyle },
'The mode is set on the plugin card under Plugins, and applies to every session: ' +
'"ask" puts a call outside the facet to you as a question; "deny" refuses it outright; ' +
'"off" disables enforcement while leaving the preset mounted. Where the deployment has no approval ' +
'channel — as here, under the danger-full-access permission preset — "ask" cannot actually reach you, ' +
'so an out-of-facet call is refused and this panel is the way to widen the scope.',
),
),
disclosure(
'How to use',
h(
'ol',
{ style: { ...bodyStyle, margin: 0, paddingLeft: '18px' } },
h('li', { style: { marginBottom: '6px' } }, 'Start the chat in the repository you want to work in.'),
h('li', { style: { marginBottom: '6px' } }, 'Set Focus to the one folder this conversation is about.'),
h(
'li',
{ style: { marginBottom: '6px' } },
'Add what it needs beyond that — shared types, project docs, a folder it will create a sibling in.',
),
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
),
),
), ),
h('li', null, 'Let the agent narrow as the work settles; it will ask you before it needs anything more.'),
), ),
), ),
// Portalled by `Modal` itself, so a nested dialog is an ordinary child
// here: the last dialog in the document owns Escape, which makes the
// picker close before the panel does.
overlay,
) )
} }
@@ -533,6 +1112,11 @@ window.__ModuleLoader__.load({
// Called before the early return, so the hook order is stable across the // Called before the early return, so the hook order is stable across the
// sessions that do and do not run the facet. // sessions that do and do not run the facet.
const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset) const preset = useSessions((state) => state.byId[sessionId]?.projectionValues?.agentPreset)
// The session's own folder, which is what "relative" means in the panel
// and where its listing starts. Read from the session list rather than
// looked up from the host, because the list already carries it and a
// second read could disagree with the row the user picked.
const cwd = useSessions((state) => state.byId[sessionId]?.cwd)
const [open, setOpen] = react.useState(false) const [open, setOpen] = react.useState(false)
if (preset !== FACET_PRESET) return null if (preset !== FACET_PRESET) return null
@@ -566,10 +1150,42 @@ window.__ModuleLoader__.load({
), ),
// Mounted only while open, so each visit starts from what the Host holds // Mounted only while open, so each visit starts from what the Host holds
// rather than from a draft left over from the last one. // rather than from a draft left over from the last one.
open ? h(FacetContextPanel, { ...props, onClose: () => setOpen(false) }) : null, open ? h(FacetContextPanel, { ...props, form, sessionId, cwd, onClose: () => setOpen(false) }) : null,
) )
} }
/**
* The directory-browsing face the picker drives.
*
* `uiWorkspace` is read lazily rather than injected. Injecting it would make
* the whole context button conditional on it, and the panel is still the
* only way to declare scope in a deployment with no browser — it would just
* have no listing to offer. `ctx.get` answers `undefined` instead of
* throwing, which is what makes that a decision rather than a crash.
*
* The availability probe is a function rather than a value because the
* renderer memoizes the registration's inject result: a boolean read there
* would freeze at whatever the first render saw, and the service may well
* activate after this bundle does.
*
* @param {object} ctx - the client context to resolve the service from.
* @returns {object} `available()` and `list()`, the two calls the panel makes.
*/
function directoryBrowse(ctx) {
const workspace = () => ctx.get('uiWorkspace')
const usable = () => typeof workspace()?.listDirectory === 'function'
return {
available: usable,
list: (path, signal) => {
const service = workspace()
if (service === undefined || typeof service.listDirectory !== 'function') {
return Promise.reject(new Error('This deployment has no directory browser to list with.'))
}
return service.listDirectory(path, signal)
},
}
}
/** /**
* Mount the facet settings card while the Host serves its namespace. * Mount the facet settings card while the Host serves its namespace.
* *
@@ -617,7 +1233,7 @@ window.__ModuleLoader__.load({
name: 'conversation.input.left', name: 'conversation.input.left',
id: CONTEXT_ENTRY_ID, id: CONTEXT_ENTRY_ID,
order: 20, order: 20,
inject: () => ({ form }), inject: () => ({ form, browse: directoryBrowse(ctx) }),
}, },
FacetContextButton, FacetContextButton,
), ),
@@ -636,12 +1252,21 @@ window.__ModuleLoader__.load({
BUNDLE_CONFIG_KEY, BUNDLE_CONFIG_KEY,
FACET_PRESET, FACET_PRESET,
CONTEXT_ENTRY_ID, CONTEXT_ENTRY_ID,
PANEL_CLASS,
PICKER_CLASS,
ROW_CLASS,
FacetCard, FacetCard,
FacetCardController, FacetCardController,
FacetContextButton, FacetContextButton,
FacetContextPanel, FacetContextPanel,
DirectoryPicker,
DirectoryPickerModel,
declaredFor, declaredFor,
writeDeclaration, writeDeclaration,
absoluteFrom,
relativeTo,
isAbsolutePath,
directoryBrowse,
} }
return module.exports return module.exports
}, },
+16 -1
View File
@@ -37,6 +37,8 @@ import {
FACET_TOOL, FACET_TOOL,
facetContextText, facetContextText,
facetRuleText, facetRuleText,
IMPLICIT_SESSION_PATHS,
implicitSearchReason,
isWideningCall, isWideningCall,
outsideReason, outsideReason,
pathArgumentsFor, pathArgumentsFor,
@@ -181,7 +183,20 @@ export function apply(ctx, config) {
if (scope === undefined) return undefined if (scope === undefined) return undefined
const paths = pathArgumentsFor(exec.name, exec.arguments) const paths = pathArgumentsFor(exec.name, exec.arguments)
if (paths.length === 0) return undefined if (paths.length === 0) {
// A tool that names no path is judged against the path it will actually
// use. For most of them that is nothing — a tool with no path argument
// cannot leave the facet. For the ones whose own default is a directory it
// is the session directory, which a narrowed facet does not contain, and
// "no opinion" there would hand the model a read the guard exists to
// refuse. The containment test is the same one; only the path is ours
// rather than the model's.
if (!IMPLICIT_SESSION_PATHS.includes(exec.name)) return undefined
const verdict = await inside(scope, scope.cwd, exec.signal)
if (verdict.inside) return undefined
const kind = mode === 'deny' ? 'deny' : 'ask'
return { kind, reason: implicitSearchReason({ cwd: scope.cwd, root: scope.root }) }
}
for (const raw of paths) { for (const raw of paths) {
const verdict = await inside(scope, raw, exec.signal) const verdict = await inside(scope, raw, exec.signal)
+94 -7
View File
@@ -97,6 +97,29 @@ export function createFacetPolicy(config) {
const grants = new Map() const grants = new Map()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */ /** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map() const narrowed = new Map()
/**
* The root resolved for each session, recorded as it is computed.
*
* A session spawned or forked from this one has no declaration of its own, so
* without this its facet would be its own working directory — wider than the
* conversation it came from, which is the one direction this whole plugin
* promises cannot happen. The recorded root already includes the parent's own
* narrowing, so a child is never wider than the parent is *now*, and the
* record's existence is what makes the inheritance work per level rather than
* for one hop.
*
* @type {Map<string, string>} session id → the root in force for it.
*/
const roots = new Map()
/**
* Child session id → the session it inherited from, for as long as both live.
*
* Kept so {@link forget} can tell a root nobody needs any more from one a
* delegated child is still answering to.
*
* @type {Map<string, string>}
*/
const spawnedFrom = new Map()
/** /**
* What the user declared for one session, read fresh. * What the user declared for one session, read fresh.
@@ -112,6 +135,23 @@ export function createFacetPolicy(config) {
return entry !== null && typeof entry === 'object' ? entry : undefined return entry !== null && typeof entry === 'object' ? entry : undefined
} }
/**
* The session this one was spawned or forked from, when the header says so.
*
* The harness's own durable lineage field, so the link survives a restart and
* a child whose parent is no longer running is still contained. `fork` records
* the session it was seeded from and a subagent its direct parent; both are
* one conversation's work continuing, which is what makes inheriting the right
* answer for either.
*
* @param {{session?: {header?: {parentSession?: unknown}}}|undefined} agent - the calling agent.
* @returns {string|undefined} the parent's session id.
*/
function parentOf(agent) {
const parent = agent?.session?.header?.parentSession
return typeof parent === 'string' && parent !== '' ? parent : undefined
}
/** /**
* The root in force for one session. * The root in force for one session.
* *
@@ -123,16 +163,22 @@ export function createFacetPolicy(config) {
* place — this one only has to notice a root that has since moved out from * place — this one only has to notice a root that has since moved out from
* under it. * under it.
* *
* Inheritance is the same kind of test, and it is skipped when the user made a
* declaration of their own: that is their decision about *this* session, and
* the point of inheriting is to answer the case where they made none.
*
* @param {string|undefined} sessionId - the session. * @param {string|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory. * @param {string} sessionCwd - its resolved working directory.
* @param {string} [inherited] - the root of the session it came from, if known.
* @returns {string} the absolute facet root. * @returns {string} the absolute facet root.
*/ */
function rootFor(sessionId, sessionCwd) { function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus const declared = declaredScope(sessionId)?.focus
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd const stated = typeof declared === 'string' && declared.trim() !== ''
const own = stated ? resolve(sessionCwd, declared) : sessionCwd
const base = !stated && inherited !== undefined && within(inherited, own) ? inherited : own
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId) const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus === undefined) return base if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
return base return base
} }
@@ -162,7 +208,13 @@ export function createFacetPolicy(config) {
* resolve against. A declaration written relative to the session keeps the * resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on. * same meaning whatever the session is focused on.
* *
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent. * Resolving a scope is also where a session's own root is *recorded*, which is
* what a session spawned from it later inherits. Every session resolves a
* scope before it can call anything — the binding is assembled for each model
* request, and the guard classifies each tool call — so a parent's root is
* always on the record by the time it can spawn or fork.
*
* @param {{session?: {header?: {cwd?: unknown, parentSession?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* @returns {FacetScope|undefined} the scope. * @returns {FacetScope|undefined} the scope.
*/ */
function scopeFor(agent) { function scopeFor(agent) {
@@ -170,9 +222,13 @@ export function createFacetPolicy(config) {
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
const sessionCwd = resolve(cwd) const sessionCwd = resolve(cwd)
const sessionId = idOf(agent) const sessionId = idOf(agent)
const parent = parentOf(agent)
if (sessionId !== undefined && parent !== undefined) spawnedFrom.set(sessionId, parent)
const root = rootFor(sessionId, sessionCwd, parent === undefined ? undefined : roots.get(parent))
if (sessionId !== undefined) roots.set(sessionId, root)
return { return {
cwd: sessionCwd, cwd: sessionCwd,
root: rootFor(sessionId, sessionCwd), root,
extras: extrasFor(agent, sessionCwd), extras: extrasFor(agent, sessionCwd),
} }
} }
@@ -284,10 +340,22 @@ export function createFacetPolicy(config) {
return [...(grants.get(sessionId) ?? [])] return [...(grants.get(sessionId) ?? [])]
} }
/** Drop a finished session's grants. Called on `session/disposed`. */ /**
* Drop a finished session's state. Called on `session/disposed`.
*
* The root is the one entry that is not simply dropped: a session spawned from
* this one may still be running, and it answers to the facet it was spawned
* under. So the record is kept while any live session still records this one
* as its parent, and released the moment none does. Without that, disposing a
* parent would quietly widen its children back to their own directory.
*
* @param {string} sessionId - the session being disposed.
*/
function forget(sessionId) { function forget(sessionId) {
grants.delete(sessionId) grants.delete(sessionId)
narrowed.delete(sessionId) narrowed.delete(sessionId)
spawnedFrom.delete(sessionId)
if (![...spawnedFrom.values()].includes(sessionId)) roots.delete(sessionId)
} }
return { return {
@@ -325,6 +393,25 @@ function isPath(value) {
return typeof value === 'string' && value.trim() !== '' return typeof value === 'string' && value.trim() !== ''
} }
/**
* Whether one absolute path is the other, or sits inside it.
*
* A string test, deliberately, and the only one in this module. The guard's
* containment check — the one that decides whether a *call* may proceed — runs
* on canonical filesystem targets, because a symlink inside the facet must not
* open it. This one only compares two roots the plugin itself derived from the
* session directory, where there is nothing to resolve and a symlink would have
* had to be introduced by the declaration.
*
* @param {string} inner - the path that may be inside.
* @param {string} outer - the path that may contain it.
* @returns {boolean} whether it is inside.
*/
function within(inner, outer) {
if (inner === outer) return true
return inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`)
}
/** /**
* The session id of a calling agent, when it has one. * The session id of a calling agent, when it has one.
* *
+49 -1
View File
@@ -54,6 +54,28 @@ export const PATH_ARGUMENTS = Object.freeze({
recall: ['path', 'focus'], recall: ['path', 'focus'],
}) })
/**
* Tools that will search the session directory when the model names no path.
*
* The guard's rule is that a call naming no path is not judged, because a tool
* that names no path cannot leave the facet. That holds for a tool with no path
* argument at all, and fails for these three: their *own* default is the session
* workspace. `grep` and `glob` document both their path arguments as "Defaults to
* the session workspace", and `recall` falls back to a directory its own marker
* search picks — an ancestor of the facet whenever the facet carries no
* `CONTROLLER.md` of its own.
*
* So on any session whose facet is narrower than its directory — every session
* whose focus was set from the panel rather than by starting the conversation in
* the folder — a bare `grep` reads outside the facet. An absent argument is not
* an absent path; it is the default one, and that is the path the guard judges.
*
* `recall` is another package's tool and is named here because the coupling to
* its two path arguments already exists in {@link PATH_ARGUMENTS}: leaving it
* out would keep a read path the table claims to cover.
*/
export const IMPLICIT_SESSION_PATHS = Object.freeze(['grep', 'glob', 'recall'])
/** The tool this plugin registers, and the action on it that widens the scope. */ /** The tool this plugin registers, and the action on it that widens the scope. */
export const FACET_TOOL = 'facet' export const FACET_TOOL = 'facet'
export const WIDENING_ACTION = 'add' export const WIDENING_ACTION = 'add'
@@ -66,7 +88,10 @@ export const WIDENING_ACTION = 'add'
* Anything that is not a non-empty string is skipped. * Anything that is not a non-empty string is skipped.
* *
* An empty result means "no opinion" — either the tool names no path (so it * An empty result means "no opinion" — either the tool names no path (so it
* cannot leave the facet) or it names one in a shape we refuse to guess at. * cannot leave the facet) or it names one in a shape we refuse to guess at. The
* first half of that is not true of every tool: the ones whose own default is a
* directory are listed in {@link IMPLICIT_SESSION_PATHS}, and the guard judges
* them against the directory they will actually search.
* *
* @param {string} toolName - the tool being called. * @param {string} toolName - the tool being called.
* @param {unknown} args - the raw model arguments. * @param {unknown} args - the raw model arguments.
@@ -236,6 +261,29 @@ export function outsideReason(input) {
return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.` return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.`
} }
/**
* Why a search that named no directory is being questioned.
*
* A separate sentence from {@link outsideReason} because the fault is different.
* There, the model named a path outside the facet and is told which one. Here it
* named nothing, so the path at issue is one it never wrote: the tool's own
* default. Saying so is the whole remedy — the fix is a second argument, not a
* grant — so the reason names the directory the search would have covered and
* the one it had to stay in.
*
* @param {object} input
* @param {string} input.cwd - the session working directory the tool defaults to.
* @param {string} input.root - the facet root.
* @returns {string} the reason.
*/
export function implicitSearchReason(input) {
const { cwd, root } = input
return (
`This search names no \`path\`, so it would cover this session's directory ${cwd} — and this session's ` +
`facet is ${root}. Name a folder inside the facet, or ask the user to grant the one you need.`
)
}
/** /**
* Why widening the scope is being put to the user. * Why widening the scope is being put to the user.
* *
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-facet", "name": "dsh-facet",
"version": "0.2.0", "version": "0.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.", "description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
@@ -50,7 +50,8 @@
"inject": [ "inject": [
"@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager", "@deepseek-ai/dsh-client-ui-plugin-manager",
"@deepseek-ai/dsh-client-ui-conversation" "@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-workspace"
] ]
} }
}, },
+315 -20
View File
@@ -35,6 +35,9 @@ async function loadClient() {
SettingsForm: 'SettingsForm', SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField', SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl', SegmentedControl: 'SegmentedControl',
// The listing's row glyph; a client half may only use what the primitives
// actually export, so the stand-in is named the same way.
IconFolderCloseRegular: 'IconFolderCloseRegular',
/** Stands in for the real model: records the specs and answers with the value. */ /** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class { SettingsFormModel: class {
constructor(scope, specs) { constructor(scope, specs) {
@@ -84,14 +87,21 @@ async function loadClient() {
} }
/** Drive `apply` against a fake slot/configForms surface and report what it did. */ /** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) { function drive(mod, { served = true, value = {}, workspace } = {}) {
const seen = { formNs: undefined, watched: undefined, registrations: [] } const seen = { formNs: undefined, watched: undefined, registrations: [] }
const disposers = [] const disposers = []
let currentSlot let currentSlot
// The optional service the context panel browses through. `get` is the
// Cordis read that answers `undefined` rather than throwing, which is what
// the plugin relies on to keep the panel in a deployment with no browser.
const get = (name) => (name === 'uiWorkspace' ? workspace : undefined)
const ctx = { const ctx = {
get,
inject: (names, scope) => { inject: (names, scope) => {
const scoped = { const scoped = {
get,
configForms: { configForms: {
get: (ns) => { get: (ns) => {
seen.formNs = ns seen.formNs = ns
@@ -170,8 +180,10 @@ test('the context button rides the composer tool row, gated on the same namespac
assert.equal(button.options.id, 'facet-context') assert.equal(button.options.id, 'facet-context')
assert.equal(typeof button.options.inject, 'function') assert.equal(typeof button.options.inject, 'function')
// It has to receive the settings form, because the panel writes through the // It has to receive the settings form, because the panel writes through the
// very same document the plugin card does. // very same document the plugin card does — and the browse face, because the
// panel's two controls are directory listings.
assert.equal(typeof button.options.inject().form, 'object') assert.equal(typeof button.options.inject().form, 'object')
assert.equal(typeof button.options.inject().browse.list, 'function')
}) })
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => { test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
@@ -291,6 +303,10 @@ test('the client half loads the packages whose slots it registers into', async (
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-plugin-manager'), 'registers into plugins.bundle.config')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-conversation'), 'registers into conversation.input.left')
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms') assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-settings'), 'reads configForms')
// Not a slot this half registers into: the panel's directory listing drives
// `uiWorkspace.listDirectory`, so the package that provides it has to be in
// the boot graph before this half asks for it.
assert.ok(inject.includes('@deepseek-ai/dsh-client-ui-workspace'), 'browses through uiWorkspace')
}) })
// ── the context button and panel ───────────────────────────────────────────── // ── the context button and panel ─────────────────────────────────────────────
@@ -364,31 +380,32 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
mutate: (ops) => (written.push(ops), true), mutate: (ops) => (written.push(ops), true),
} }
const tree = mod.FacetContextPanel({ form, sessionId: 'session-1', mode: 'deny', onClose: () => {} }) const tree = mod.FacetContextPanel({
form,
sessionId: 'session-1',
cwd: '/work/root',
browse: { available: () => true, list: () => Promise.resolve() },
onClose: () => {},
})
assert.equal(tree.type, 'Modal') assert.equal(tree.type, 'Modal')
// The class is the whole width fix: the primitive's own card is 380px and it
// takes only a class name, so the stylesheet this bundle installs outranks it.
assert.equal(tree.props.className, mod.PANEL_CLASS)
// Walk the tree for the text and controls a user would see. // Walk the tree for the text and controls a user would see.
const texts = [] const texts = textsOf(tree)
const buttons = [] const buttons = buttonsOf(tree)
const inputs = [] const inputs = []
const walk = (node) => { walkTree(tree, (node) => {
if (typeof node === 'string') return texts.push(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) return node.forEach(walk)
if (node.type === 'button') buttons.push(node)
if (node.type === 'input') inputs.push(node) if (node.type === 'input') inputs.push(node)
// Children arrive either as createElement arguments or, as the card does it, })
// through the `children` prop.
if (Array.isArray(node.children)) node.children.forEach(walk)
walk(node.props?.children)
}
walk(tree)
const labelOf = (node) => (node.children ?? [])[0] const labelOf = (node) => (node.children ?? [])[0]
const prose = texts.join('\n') const prose = texts.join('\n')
assert.match(prose, /What this conversation may see/) assert.match(prose, /What this conversation may see/)
// The two tabs the preset picker cannot show for a custom preset live here. // The two tabs the preset picker cannot show for a custom preset live here,
// folded away so the controls are not buried under them.
assert.match(prose, /Mode details/) assert.match(prose, /Mode details/)
assert.match(prose, /How to use/) assert.match(prose, /How to use/)
// The honest bit about this deployment, so the panel does not promise a // The honest bit about this deployment, so the panel does not promise a
@@ -397,9 +414,287 @@ test('the panel renders the scope, the guidance, and a way to add and remove', a
assert.match(prose, /danger-full-access/) assert.match(prose, /danger-full-access/)
// The declared scope is on screen, and the existing extra has its own control. // The declared scope is on screen, and the existing extra has its own control.
assert.equal(inputs[0].props.value, 'src/room') assert.ok(texts.includes('src/room'), 'the focus is shown as the declared path')
assert.ok(texts.some((text) => text.includes('/work/root')), 'the session folder relative to what is named')
assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1) assert.equal(buttons.filter((button) => labelOf(button) === 'Remove').length, 1)
assert.ok(buttons.some((button) => labelOf(button) === 'Add'))
// Both controls are the listing. `Set` is gone with the box it committed:
// picking writes the declaration straight away, as every other control here
// already did.
assert.ok(buttons.some((button) => labelOf(button) === 'Browse\u2026'))
assert.ok(buttons.some((button) => labelOf(button) === 'Clear')) assert.ok(buttons.some((button) => labelOf(button) === 'Clear'))
assert.ok(buttons.some((button) => labelOf(button) === 'Set')) assert.ok(buttons.some((button) => labelOf(button) === '+ Add path or file\u2026'))
assert.ok(!buttons.some((button) => labelOf(button) === 'Set'))
assert.deepEqual(inputs, [], 'the panel itself has no free-text box left')
}) })
test('a deployment with no directory browser keeps the panel and says so', async () => {
const { module: mod } = await loadClient()
const form = {
getSnapshot: () => ({ status: 'ready', writable: true, revision: 1, value: { mode: 'deny', scopes: {} } }),
subscribe: () => () => {},
mutate: () => true,
}
const tree = mod.FacetContextPanel({
form,
sessionId: 's',
cwd: '',
browse: mod.directoryBrowse({ get: () => undefined }),
onClose: () => {},
})
const texts = textsOf(tree)
const label = (button) => (button.children ?? [])[0]
// The panel is the only way to declare scope, so it renders without a
// browser: the listing affordances go dead, the explanation stays.
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === 'Browse\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.deepEqual(
buttonsOf(tree)
.filter((button) => label(button) === '+ Add path or file\u2026')
.map((button) => button.props.disabled),
[true],
)
assert.ok(texts.some((text) => text.includes('no directory browser')))
// The mode is read from the document rather than from a slot prop that never
// carried it, so the line does not claim "ask" whatever the setting is.
assert.ok(texts.some((text) => text.includes('Deployment mode: deny')))
})
// ── the directory listing ────────────────────────────────────────────────────
/** A listing shaped the way the Host's browse primitive answers. */
function listingFixture() {
return {
path: '/work/root/src',
home: '/home/dev',
crumbs: [
{ name: '/', path: '/', hidden: false },
{ name: 'work', path: '/work', hidden: false },
{ name: 'root', path: '/work/root', hidden: false },
{ name: 'src', path: '/work/root/src', hidden: false },
],
entries: [
{ name: 'shared', path: '/work/root/src/shared', hidden: false },
{ name: '.git', path: '/work/root/src/.git', hidden: true },
],
truncated: false,
}
}
/** Let the model's own promise chain settle. */
function settle() {
return new Promise((resolve) => setTimeout(resolve, 0))
}
/** Every button in a rendered tree, in document order. */
function buttonsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (node.type === 'button') found.push(node)
})
return found
}
/** Every string in a rendered tree, in document order. */
function textsOf(tree) {
const found = []
walkTree(tree, (node) => {
if (typeof node === 'string') found.push(node)
})
return found
}
/** Walk a rendered tree the way it was built: children as arguments or as a prop. */
function walkTree(node, visit) {
if (typeof node === 'string') return visit(node)
if (node === null || node === undefined || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walkTree(child, visit)
return
}
visit(node)
if (Array.isArray(node.children)) node.children.forEach((child) => walkTree(child, visit))
// `footer` is a prop rather than a child, and it is rendered: a control there
// is as much part of the tree as one in the body.
walkTree(node.props?.children, visit)
walkTree(node.props?.footer, visit)
}
test('a path is declared relative to the session folder wherever it can be', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.relativeTo('/work/root', '/work/root'), '', 'the session folder itself has no name of its own')
assert.equal(mod.relativeTo('/work/root', '/work/root/src/shared'), 'src/shared')
assert.equal(mod.relativeTo('/work/root/dsh-facet', '/work/root'), '..')
assert.equal(mod.relativeTo('/work/root/dsh-facet/lib', '/work/root'), '../..')
// Nothing shared: another tree, or another drive. Absolute is the only
// spelling that still names it.
assert.equal(mod.relativeTo('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.relativeTo('C:\\work\\root', 'D:\\work\\root'), 'D:\\work\\root')
assert.equal(mod.relativeTo('', '/srv/other'), '/srv/other')
// Windows separators split and join like POSIX ones.
assert.equal(mod.relativeTo('C:\\work\\root', 'C:\\work\\other'), '../other')
assert.equal(mod.absoluteFrom('/work/root', 'src/shared'), '/work/root/src/shared')
assert.equal(mod.absoluteFrom('/work/root', '/srv/other'), '/srv/other')
assert.equal(mod.absoluteFrom('/work/root', ''), '', 'empty stays empty: it means the session folder')
assert.equal(mod.absoluteFrom('', 'src/shared'), 'src/shared', 'with no folder to join, a relative path is left alone')
assert.equal(mod.isAbsolutePath('C:\\x'), true)
assert.equal(mod.isAbsolutePath('\\\\server\\share'), true)
assert.equal(mod.isAbsolutePath('src/x'), false)
})
test('the browse face is read from uiWorkspace at the call, not at registration', async () => {
const { module: mod } = await loadClient()
// Absent service: the probe answers false and the call rejects with something
// the picker can show rather than a TypeError from an undefined property.
const missing = mod.directoryBrowse({ get: () => undefined })
assert.equal(missing.available(), false)
await assert.rejects(missing.list('/tmp'), /no directory browser/)
const calls = []
const present = mod.directoryBrowse({
get: () => ({
listDirectory: (path, signal) => {
calls.push([path, signal])
return Promise.resolve('listing')
},
}),
})
assert.equal(present.available(), true)
assert.equal(await present.list('/tmp', 'signal'), 'listing')
assert.deepEqual(calls, [['/tmp', 'signal']])
})
test('the picker model lists a level, supersedes a stale one, and keeps a typed path on failure', async () => {
const { module: mod } = await loadClient()
const asked = []
const model = new mod.DirectoryPickerModel({
list: (path) => {
asked.push(path)
return Promise.resolve(listingFixture())
},
root: '/work/root',
start: '/work/root/src',
})
await settle()
assert.deepEqual(asked, ['/work/root/src'], 'the opening level is listed, not guessed at')
assert.equal(model.getSnapshot().listing.path, '/work/root/src')
assert.equal(model.getSnapshot().draft, '/work/root/src', 'the field follows a successful listing')
assert.equal(model.getSnapshot().loading, false)
// A second navigation wins even when the first is still in flight: the
// sequence number is what stops an early listing landing on a later view.
model.open('/work/root/other')
model.setDraft('notes/CONTROLLER.md')
// A failure keeps the level on screen and the path as typed: the Host lists
// folders, and the extras legitimately name a file.
const failing = new mod.DirectoryPickerModel({
list: () => Promise.reject(Object.assign(new Error('transport'), { rpcError: { message: 'directory-unreadable' } })),
root: '/work/root',
start: 'notes/CONTROLLER.md',
})
await settle()
assert.equal(failing.getSnapshot().error, 'directory-unreadable', 'the Host business message is preferred')
assert.equal(failing.getSnapshot().draft, 'notes/CONTROLLER.md')
})
test('the picker lists folders, hides hidden ones until asked, and answers in declared form', async () => {
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
list: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const picked = []
const render = () =>
mod.DirectoryPicker({
model,
root: '/work/root',
target: 'focus',
disabled: false,
onClose: () => {},
onPick: (path) => picked.push(path),
})
const tree = render()
assert.equal(tree.type, 'Modal')
assert.equal(tree.props.className, mod.PICKER_CLASS)
assert.equal(tree.props.title, 'Choose the focus folder')
// The level is the rows, one per listed folder; the trail above them is the
// ancestry, and a hidden entry stays hidden until the toggle asks for it.
const rowTitles = (node) =>
buttonsOf(node)
.filter((button) => button.props.className === mod.ROW_CLASS)
.map((button) => button.props.title)
assert.deepEqual(rowTitles(tree), ['/work/root/src/shared'])
assert.ok(
buttonsOf(tree).some((button) => button.props.title === '/work/root' && (button.children ?? [])[0] === 'root'),
'the trail offers the ancestry as jump targets',
)
// The footer's own action takes the field's value and hands it back in the
// declared form, so the workspace can move without the grant meaning
// something else.
const footer = tree.props.footer
const pickButton = footer.find((entry) => (entry.children ?? [])[0] === 'Set focus')
assert.notEqual(pickButton, undefined, 'the footer commits the picked path')
pickButton.props.onClick()
assert.deepEqual(picked, ['src'])
model.setShowHidden(true)
assert.deepEqual(rowTitles(render()), ['/work/root/src/shared', '/work/root/src/.git'])
// A row is a way down, not a pick: standing in the folder is what chooses it.
model.open('/work/root/other')
await settle()
})
test('the extras picker names what it accepts and says what the list holds', async () => {
const { module: mod } = await loadClient()
const model = new mod.DirectoryPickerModel({
list: () => Promise.resolve(listingFixture()),
root: '/work/root',
start: '/work/root/src',
})
await settle()
const tree = mod.DirectoryPicker({
model,
root: '/work/root',
target: 'extra',
disabled: false,
onClose: () => {},
onPick: () => {},
})
// "folder or path" promised one thing and offered another: the rows are
// folders, and a file is added by naming it. The copy says both.
assert.equal(tree.props.title, 'Add a path or a file')
const texts = textsOf(tree)
assert.ok(texts.includes('Path or file'), 'the field is named for what it accepts')
assert.ok(
texts.some((text) => text.includes('The list holds folders')),
'the copy explains why no file rows are shown',
)
assert.ok(
buttonsOf(tree).some((button) => (button.children ?? [])[0] === 'Add'),
'the action is Add for an extra, not Set focus',
)
})
+125 -1
View File
@@ -170,13 +170,59 @@ test('a prefix is not a parent', async () => {
assert.equal(decision.kind, 'ask') assert.equal(decision.kind, 'ask')
}) })
test('an omitted path means the session directory, which is in scope', async () => { test('an omitted path means the session directory, which here is the facet', async () => {
const { captured } = harness() const { captured } = harness()
// Allowed because the default and the facet are the same folder — not because
// the call named nothing. The next test is the case where they differ.
assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), { assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), {
kind: 'allow', kind: 'allow',
}) })
}) })
test('a search that names no path is judged against the directory it would cover', async () => {
// The session directory is the facet's parent here: the user narrowed this
// conversation to `inner`, while the search tools still default to the whole
// session directory. An absent argument is not an absent path — it is the
// default one, and the default one leaves the facet.
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
const decision = await dispatch(captured, { name: 'grep', args: { pattern: 'motion_learning' }, agent: agent() })
assert.equal(decision.kind, 'ask')
assert.match(decision.reason, /names no `path`/)
assert.match(decision.reason, /inner/, 'the reason names the boundary the call crossed')
// Naming the folder is the whole remedy, and it costs no grant.
assert.deepEqual(
await dispatch(captured, { name: 'grep', args: { pattern: 'x', path: 'inner' }, agent: agent() }),
{ kind: 'allow' },
)
// The same rule reaches the other two tools whose default is a directory.
assert.equal(
(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() })).kind,
'ask',
)
assert.equal(
(await dispatch(captured, { name: 'recall', args: { query: 'motion learning' }, agent: agent() })).kind,
'ask',
)
// A tool with no path argument at all is still left alone: it has no default
// that could leave the facet.
assert.deepEqual(await dispatch(captured, { name: 'todo_write', args: { todos: [] }, agent: agent() }), {
kind: 'allow',
})
})
test('recall is judged on the scope it names, because that is what it will search', async () => {
const { captured } = harness({ config: { mode: 'deny', scopes: { 'session-1': { focus: 'inner' } } } })
assert.deepEqual(await dispatch(captured, { name: 'recall', args: { query: 'x', focus: 'inner' }, agent: agent() }), {
kind: 'allow',
})
assert.equal(
(await dispatch(captured, { name: 'recall', args: { query: 'x', path: '..' }, agent: agent() })).kind,
'deny',
)
})
test('tools the guard cannot judge are left alone, deliberately', async () => { test('tools the guard cannot judge are left alone, deliberately', async () => {
const { captured } = harness() const { captured } = harness()
// A shell command cannot be read reliably from argv, so the facet is enforced // A shell command cannot be read reliably from argv, so the facet is enforced
@@ -195,6 +241,84 @@ test('an agent with no working directory is not scoped', async () => {
assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' }) assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' })
}) })
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from a scoped one has no declaration of its own,
// and the user's declaration is keyed by session id. Without inheritance its
// facet would be its own working directory — wider than the conversation it came
// from, which is the one direction this plugin promises cannot happen.
/** A session that was spawned from another, as the harness records lineage. */
const childOf = (parentId, id = 'child-1', cwd = facet) => ({
session: { id, header: { cwd, parentSession: parentId } },
})
test('a session spawned from a scoped one starts inside the same facet', async () => {
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
// The parent's root is on the record as soon as it resolves a scope, which
// happens for every model request and every tool call — so before it can
// spawn anything.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
const child = childOf('session-1')
// Without inheritance this would be ordinary work for the child: its root
// would be the session directory, one level up from its parent's facet.
const decision = await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: child })
assert.equal(decision.kind, 'ask')
assert.match(decision.reason, /inner/)
// Inside the inherited facet is inside the child's scope, as it should be.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: child }),
{ kind: 'allow' },
)
})
test('inheritance holds at depth, and is not a way around a self-narrowing', async () => {
// No declaration: the user left the session at its directory, and the only
// thing that will keep a child inside `inner` is the inherited root.
const { captured } = harness({ config: { mode: 'deny' } })
await captured.tool.execute({ action: 'focus', paths: ['inner'] }, { agent: agent() })
const child = childOf('session-1')
const grandchild = childOf('child-1', 'grand-1')
// The child's own scope is resolved by its first call, which is what puts its
// root on the record for the grandchild to inherit.
for (const descendant of [child, grandchild]) {
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: descendant })).kind,
'allow',
`${descendant.session.id} is inside the inherited facet`,
)
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: descendant })).kind,
'deny',
`${descendant.session.id} cannot delegate its way back out of the narrowing`,
)
}
})
test('a declaration for the child itself stands over what it would inherit', async () => {
const { captured } = harness({
config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' }, 'child-1': { focus: '.' } } },
})
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
// The user is the authority on this session too: a declaration made for the
// child is a statement about the child, and inheritance only answers the case
// where they made none.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: childOf('session-1') }),
{ kind: 'allow' },
)
})
test('a standing grant and a session grant both open the scope', async () => { test('a standing grant and a session grant both open the scope', async () => {
const standing = harness({ extraRoots: ['../../shared'] }) const standing = harness({ extraRoots: ['../../shared'] })
assert.deepEqual( assert.deepEqual(
+58
View File
@@ -187,3 +187,61 @@ test('junk in a declaration is ignored rather than thrown on', () => {
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok']) assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w') assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
}) })
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from this one has no declaration of its own, and
// the user's declaration is keyed by session id. What stops it starting wider
// than its parent is the parent's own resolved root, recorded as the parent
// resolves a scope — which it does for every model request and every tool call,
// so the record is always there before it can spawn anything.
/** An agent whose header carries the lineage the harness writes for a child. */
const spawned = (id, parentSession, cwd = '/w') => ({
session: { id, header: { cwd, parentSession } },
})
test('a session spawned from a scoped one inherits its root, at any depth', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
const child = spawned('c1', 's1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// Relative paths still resolve from the child's own directory, as for anyone.
assert.equal(policy.scopeFor(child).cwd, '/w')
assert.equal(policy.scopeFor(spawned('g1', 'c1')).root, '/w/src/room')
})
test('a child is never wider than its parent is now, narrowing included', () => {
const policy = createFacetPolicy({ mode: 'deny' })
policy.narrowTo('s1', '/w/src/room')
// The narrowing is the agent's own decision; spawning a child is not a way to
// undo it, because the root the child inherits already includes it.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w/src/room')
})
test('a declaration for the child itself stands over what it would inherit', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' }, c1: { focus: '.' } } })
policy.scopeFor(agentAt('/w', 's1'))
// Inheritance answers the case where the user made no statement about this
// session; a statement they did make is the more specific one and stands.
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w')
})
test('a root is kept while a session that inherited it is still alive', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
const child = spawned('c1', 's1')
policy.scopeFor(agentAt('/w', 's1'))
policy.scopeFor(child)
// Disposing the parent must not quietly widen the child that is still running.
policy.forget('s1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// With nothing left to inherit it, the record goes too.
policy.forget('c1')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
})