fix: show the settings card, and make an edit actually take effect

`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.

It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.

A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
This commit is contained in:
NotExpectedYet committed 2026-09-26 07:13:33 +01:00
1 parent c41c555400
commit 28f20a8aac
8 files changed
+283 -59

No files matched your search

+25 -10
View File
@@ -64,10 +64,19 @@ export const Config = Schema.object({
*/
export function apply(ctx, config) {
const policy = ctx.facetPolicy
/** @type {'ask'|'deny'|'off'} */
const mode = policy.mode
if (mode === 'off') return
// Read at the point of use, never captured. `mode` is a volatile field, so a
// settings edit updates the policy's reference in place and deliberately does
// not re-apply this plugin; a `const mode` here would answer with the value
// the session was loaded with, for the life of the session.
const modeNow = () => policy.mode
// `off` is the one part that stays a mount-time decision. Deciding it here
// means an `off` deployment contributes no rule, no binding and no tool —
// which is the point of the mode. The cost is that turning it *on* later needs
// a reload, because a volatile-only edit never re-runs this function. `ask`
// and `deny` swap freely in both directions.
if (modeNow() === 'off') return
const sectionOrder = policy.sectionOrder ?? 100
const contextOrder = policy.contextOrder ?? 105
@@ -129,6 +138,9 @@ export function apply(ctx, config) {
const classify = async (exec) => {
if (exec.signal?.aborted) return undefined
const mode = modeNow()
if (mode === 'off') return undefined
// Widening is the one act that always needs the user, whatever the mode:
// it is explicit, deliberate, and the only thing standing between a scoped
// agent and an unscoped one.
@@ -155,11 +167,13 @@ export function apply(ctx, config) {
}
// The rule. `interpolate: false` because every value in it is already
// literal; there is nothing here for the harness to substitute.
// literal; there is nothing here for the harness to substitute. It carries no
// `mode`, because a section is registered once and cannot follow a live edit —
// see `facetRuleText`.
ctx.systemPrompt.section({
name: 'facet-scope',
order: Number(sectionOrder),
text: facetRuleText({ mode }),
text: facetRuleText(),
interpolate: false,
})
@@ -173,11 +187,11 @@ export function apply(ctx, config) {
text: (context) => {
const scope = policy.scopeFor(context?.agent)
if (scope === undefined) return ''
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode })
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode: modeNow() })
},
})
if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode))
if (config.tool) ctx.tools.register(facetTool(ctx, policy))
// `next()` first, so an earlier policy plugin keeps its say: this listener
// only ever adds a question or a refusal to a call that was otherwise allowed.
@@ -206,9 +220,8 @@ function requestedPaths(args) {
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @param {object} policy - the `facetPolicy` service.
* @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode.
*/
function facetTool(ctx, policy, mode) {
function facetTool(ctx, policy) {
return defineTool({
name: FACET_TOOL,
description:
@@ -276,7 +289,9 @@ function facetTool(ctx, policy, mode) {
ok: true,
root: scope.root,
facet: scope.root,
mode,
// Read here rather than captured at mount, so `status` reports the mode
// in force now instead of the one this session started under.
mode: String(policy.mode),
standing: policy.standing,
granted,
scope: scope.extras,
+64 -6
View File
@@ -18,6 +18,38 @@
import { isAbsolute, resolve } from 'node:path'
/**
* The symbol cosmokit uses to mark a volatile config reference.
*
* Looked up by symbol rather than by importing cosmokit, because the ESM and CJS
* copies of that library are distinct module instances and `Symbol.for` is the
* only identity they share.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/**
* Read one configuration field.
*
* A `volatile()` field is a *reference* in the running config, not a plain
* value: the framework resolves the schema through `Schema.resolve`, which wraps
* each volatile field in `createVolatile(...)` — `{get(), [write]}`. A
* non-volatile field, or a config object a test handed straight to
* {@link createFacetPolicy}, is the value itself. Both are accepted, so one code
* path serves a real profile and a direct call.
*
* Every read has to happen at the point of use rather than once at construction.
* A settings write commits into these references in place and deliberately does
* *not* re-apply the plugin, so anything captured at construction would never
* see the change — which is how a plugin ends up with a settings card that does
* nothing.
*
* @param {unknown} value - a field of the resolved config.
* @returns {unknown} the current value.
*/
export function configValue(value) {
return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value
}
/**
* @typedef {object} FacetScope
* @property {string} cwd - the session working directory, absolute.
@@ -28,6 +60,13 @@ import { isAbsolute, resolve } from 'node:path'
/**
* Build the policy for one deployment.
*
* `config` is the *resolved* composition config, so its volatile fields are
* references rather than values; every read below goes through
* {@link configValue} and happens per use. Holding the config and reading it lazily
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
*
* @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
@@ -36,7 +75,20 @@ import { isAbsolute, resolve } from 'node:path'
export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
const grants = new Map()
const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : []
/**
* The deployment's standing grants, read fresh on every call.
*
* Not filtered once at construction: `extraRoots` is volatile, so an edit in
* the settings UI replaces the reference's contents without remounting this
* plugin, and the next scope resolution is expected to see it.
*
* @returns {string[]} configured paths, blanks dropped.
*/
function standingRoots() {
const configured = configValue(config.extraRoots)
return Array.isArray(configured) ? configured.filter(isPath) : []
}
/**
* The scope of one agent, or `undefined` when it has none.
@@ -66,7 +118,7 @@ export function createFacetPolicy(config) {
function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
return [...new Set(all)]
}
@@ -124,11 +176,17 @@ export function createFacetPolicy(config) {
}
return {
mode: config.mode,
/** The enforcement mode. A getter, so a settings edit is answered on the next call. */
get mode() {
return configValue(config.mode)
},
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: config.sectionOrder ?? 100,
contextOrder: config.contextOrder ?? 105,
standing: [...standing],
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** The standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
scopeFor,
grant,
revoke,
+28 -18
View File
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
* treatment the sandbox policy and approval policy already get, for the same
* reason.
*
* @param {object} input
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
* @returns {string} prompt section text, or `''` to contribute nothing.
* It takes no configuration, and that is deliberate rather than an oversight.
* A system-prompt section is a plain string registered once; there is no
* callback to re-render it, and a settings write to a `volatile()` field
* updates the config reference *without* re-applying the plugin. So anything
* this function interpolated would freeze at whatever the value was at load —
* the prompt would go on describing the old `mode` while the guard enforced the
* new one. Everything that depends on `mode` therefore lives in the binding,
* which is a callback and therefore live.
*
* @returns {string} prompt section text.
*/
export function facetRuleText(input) {
const { mode } = input
if (mode === 'off') return ''
const reach =
mode === 'deny'
? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.'
: 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.'
export function facetRuleText() {
return [
'# Facet scope',
'',
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.',
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which, and says how a call outside it is treated.',
'',
`Work inside the facet. ${reach}`,
'Work inside the facet.',
'',
'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.',
'',
@@ -164,9 +163,12 @@ export function displayPath(cwd, path) {
/**
* The mutable half of the facet, for the runtime-context snapshot.
*
* Kept to three short lines because it is re-sent as part of the conversation
* whenever it changes, and a scope statement that costs more than the scope is
* worth is a scope that does not pay for itself.
* Kept short because it is re-sent as part of the conversation whenever it
* changes, and a scope statement that costs more than the scope is worth is a
* scope that does not pay for itself. It carries one sentence more than the bare
* binding used to, because `mode` now belongs here: this is the only half that
* is re-rendered per request, so it is the only half that can follow a settings
* change. See {@link facetRuleText}.
*
* @param {object} input
* @param {string} input.cwd - the session working directory.
@@ -179,13 +181,21 @@ export function facetContextText(input) {
const { cwd, root, extras = [], mode } = input
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
// Stated in terms of what the model should *do*, because the same sentence is
// what the user reads in an approval prompt. A denial that does not say how to
// proceed is a denial the agent works around with `bash`.
const reach =
mode === 'deny'
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
// The root is stated absolutely even though it is the session's own directory
// and a relative form would be shorter. `facet: .` is true and useless: the
// model needs a path it can reason about, and the user reading an approval
// prompt needs to see which folder they are being asked about.
const lines = [`facet: ${root}`]
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`)
lines.push(`outside the facet: ${reach}`)
return lines.join('\n')
}