/**
 * Agent presets: each session composes its model-facing plugin set from one
 * preset `cordis.yml`, mounted ONCE per preset under a standing scope and
 * joined by every agent that names it.
 *
 * The standing mount is what makes a preset one composition rather than one
 * per session: its plugin instances, tool registrations, prompt sections, and
 * projection units exist exactly once, keyed per session inside the plugins
 * themselves (they predate presets and were written for a shared world). An
 * agent joins by having its scope key parented to the mount's
 * ({@link bindScopeParent}), which makes the mount's registrations visible to
 * that agent's views and the mount's listeners receive that agent's events —
 * and a host reader with no agent at all (a cold transcript read) resolves
 * the same standing registrations by preset id.
 *
 * This package owns the preset vocabulary, filesystem discovery, and the
 * guarded standing mount. It does not decide when an agent is created — the
 * agent factory's `setup(agentCtx)` hook is the one supported call site,
 * because only there is the join installed while the agent is still
 * unpublished, so a rejected composition rolls the whole creation back.
 * @module @deepseek-ai/dsh-agent-presets
 */
import { stat } from 'node:fs/promises';
import { Service } from '@deepseek-ai/cordis';
import z from '@deepseek-ai/schemastery';
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope';
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
import { discoverPresets, USER_PRESET_DIR } from "./discovery.js";
import { copyComposition, deleteComposition, readComposition } from "./authoring.js";
import { mountPreset, serviceForAgent, standingMountFor } from "./mount.js";
import { PresetExistsError } from "./authoring.js";
import { PresetMountError, UnknownPresetError } from "./preset.js";
/** Settings namespace carrying the user's chosen default preset. */
export const SETTINGS_NAMESPACE = 'agent-presets';
/** Runtime schema for the user-writable slice. */
export const AgentPresetSettingsSchema = z.object({
    default: z.string(),
});
export { COMPOSITION_FILE, discoverPresets, scanRoot } from "./discovery.js";
export { METADATA_FILE, readPresetMetadata, renderPresetMetadata, } from "./metadata.js";
export { inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor, } from "./mount.js";
export { copyComposition, deleteComposition, InvalidPresetIdError, PresetExistsError, PresetNotWritableError, readComposition, writableRoot, } from "./authoring.js";
export { resolveSessionPreset } from "./session.js";
export { PresetMountError, UnknownPresetError } from "./preset.js";
/**
 * Registry over the deployment's agent presets.
 *
 * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every
 * call so a preset authored while the process runs is visible immediately,
 * and a preset deleted underneath a picker disappears from the next read.
 */
export class AgentPresets extends Service {
    config;
    static inject = ['loader'];
    /** Runtime schema for the preset roster. */
    static Config = z.object({
        default: z.string().required(),
        roots: z.array(z.object({
            path: z.string().required(),
            trust: z.union(['system', 'user']).default('user'),
        })).default([]),
        includeUserRoot: z.boolean().default(true),
    });
    /**
     * The roots discovery and authoring actually scan: every configured root in
     * order, then the harness-home user root unless `includeUserRoot` is false.
     *
     * Derived once, because a root set that changed between `list()` and the
     * `copy()` acting on its answer would author into a directory the caller
     * never saw. Appending rather than prepending keeps an earlier configured
     * root winning a duplicate id, so a shipped preset still shadows a
     * locally authored directory that claimed its name.
     */
    resolvedRoots;
    /**
     * The user layer over `config.default`, present only while a settings
     * provider is composed. Held rather than snapshotted so a hot-reloaded
     * document takes effect without a restart.
     */
    settings;
    /**
     * The settings service behind {@link settings}, held for the one write this
     * service makes: clearing a user default it has just deleted.
     */
    settingsService;
    /**
     * The service's own untraced context. Methods invoked through the traceable
     * proxy see `this.ctx` rebound to the CALLER's context, which carries a
     * shadow; a subtree minted from it resolves every service through that
     * shadow's fiber instead of each entry's own inject store, so preset rows
     * would fail on the very services they declare. Standing mounts must hang
     * off the untraced original (the `jobs-local` selfCtx precedent).
     */
    selfCtx;
    constructor(ctx, config) {
        super(ctx, 'agentPresets');
        this.config = config;
        this.selfCtx = ctx;
        this.resolvedRoots = config.includeUserRoot
            ? [...config.roots, { path: dshHomePath(USER_PRESET_DIR), trust: 'user' }]
            : [...config.roots];
        // Deliberately not `installSettingsSection`: that helper exists to re-judge
        // what a consumer DERIVED from the source — memoized resolutions,
        // registration-level facts — across attach, detach, and change. Nothing
        // here is derived. `defaultId` reads through on every call, so both of its
        // hooks would be no-ops and the source thunk would restate this field.
        ctx.inject(['settings'], (settingsCtx) => {
            this.settings = settingsCtx.settings.register(settingsNamespace(SETTINGS_NAMESPACE), AgentPresetSettingsSchema, { base: { default: config.default } });
            this.settingsService = settingsCtx.settings;
            settingsCtx.effect(() => () => {
                this.settings = undefined;
                this.settingsService = undefined;
            }, 'agentPresets.settings()');
        });
        // Advisory, not fatal: a synchronous `agent/created` listener that throws
        // VETOES publication, and this service must not, because composing an agent
        // outside the roster is legal — `recompose` binds exactly such a bare agent
        // below, and the ACP, SDK-server, and headless entry points all create one.
        // The invariant companion is the check that fails loud, at assembly. Why an
        // unjoined agent matters at all has one home: the [Agent
        // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md).
        //
        // Known false positive: a session created bare and bound later by
        // `recompose` is warned about once, before its first bind. No shipped flow
        // does that today — the Web surface mounts in `setup` and children join
        // through `composeFrom` before publication.
        ctx.on('agent/created', ({ agent }) => {
            if (this.resolvedRoots.length === 0)
                return;
            if (this.composedPreset(agent.ctx) !== undefined)
                return;
            ctx.logger.warn(`agent "${agent.id}" was published without joining an agent preset; `
                + 'its tools, prompt sections, and skill catalog resolve against the empty global layer '
                + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)');
        });
        // The durable record is the commit point. Its public notification carries
        // only the stable identity needed by clients, never the live Session.
        ctx.on('session/event', (session, event) => {
            if (event.type !== 'agent-preset/selected')
                return;
            ctx.emit('agent-preset/selected', session.id, event.data.agentPreset);
        });
    }
    /**
     * The preset id mounted when a caller names none.
     *
     * Read per call rather than cached: the settings document is hot-reloaded, so
     * changing the default takes effect on the next session created and leaves
     * every running session on the preset it was composed from.
     */
    get defaultId() {
        return this.settings?.get().default ?? this.config.default;
    }
    /**
     * Every preset the configured roots currently supply.
     * @returns the presets, first-root-wins per id.
     */
    async list() {
        return await discoverPresets(this.resolvedRoots);
    }
    /**
     * Resolve one preset by id.
     *
     * A broken preset resolves — deleting one, reading one, and reporting one
     * all need the row — and the mounting paths refuse it AFTER resolution
     * through {@link resolveMountable}.
     * @param id - the preset id, or `undefined` for {@link defaultId}.
     * @returns the resolved preset.
     * @throws when no configured root supplies that id.
     */
    async resolve(id) {
        const wanted = id ?? this.defaultId;
        const presets = await this.list();
        const found = presets.find(preset => preset.id === wanted);
        if (found === undefined) {
            throw new UnknownPresetError(wanted, presets.map(preset => preset.id));
        }
        return found;
    }
    /**
     * Resolve one preset that is about to compose an agent, refusing a broken
     * one with its discovery-reported reason. Failing here rather than inside
     * the loader keeps the answer the same for every unloadable shape — ghost
     * directory, unparsable YAML, rowless list — and spends no mount attempt
     * on a composition discovery already read as unusable.
     * @param id - the preset id, or `undefined` for {@link defaultId}.
     * @returns the resolved, mountable preset.
     * @throws when the preset is unknown or discovery reports it broken.
     */
    async resolveMountable(id) {
        const preset = await this.resolve(id);
        if (preset.broken !== undefined) {
            throw new PresetMountError(preset.id, preset.broken);
        }
        return preset;
    }
    /**
     * Standing mounts by preset id, single-flight so two agents racing the
     * first use of one preset share one composition. A settled failure is
     * removed so a later session retries a preset whose file has been fixed; a
     * settled success serves until the composition FILE visibly changes — each
     * generation records its file stamp, and a stale stamp starts the next
     * generation for sessions created afterwards. Sessions already joined keep
     * the generation they run on; a superseded one is never disposed while the
     * process lives (reclaimed only by whole-tree teardown), so editing files
     * is bounded by how often compositions change, not by session count.
     */
    standing = new Map();
    /**
     * Parent bindings of the agents this roster composed, keyed by the agent's
     * scope key. The binding is dsh-scope's only re-link capability; holding it
     * here makes this service the sole authority that can move an agent between
     * standing compositions. WeakMap: entries die with their agents.
     */
    bindings = new WeakMap();
    /**
     * Compose one agent from a preset: ensure the preset's standing mount, then
     * parent the agent's scope key to it so the mount's registrations and
     * listeners cover this agent.
     *
     * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls
     * the agent creation back, so a broken preset never yields a half-composed
     * session.
     * @param agentCtx - the agent's scope context.
     * @param id - the preset id, or `undefined` for {@link defaultId}.
     * @returns the preset that was composed, for the caller to record.
     * @throws when the preset is unknown or its composition is unusable.
     */
    async mount(agentCtx, id) {
        const agentKey = scopeOf(agentCtx);
        if (agentKey === undefined) {
            throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset');
        }
        const preset = await this.resolveMountable(id);
        const standing = await this.ensureStanding(preset);
        // The one bind of this agent's ancestry. The binding is the only re-link
        // authority, held privately so nothing outside this roster can move a
        // composed agent to another preset; a later recompose layer re-links
        // through it under the caller-owned blank-session contract.
        this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
        return preset;
    }
    /**
     * Join one agent to the SAME standing composition another already runs on.
     *
     * This is how a child agent inherits its parent's capabilities. It is a bind,
     * not a mount: the parent's generation is already composed, so the child gets
     * that exact instance — the same plugin objects, the same tool registrations,
     * the same prompt sections. Re-resolving the parent's preset by id instead
     * would re-read the roster, and a composition file edited since the parent
     * started would hand the child a DIFFERENT generation than the one its
     * parent's history was produced under (and a preset deleted since would fail
     * the child outright while its parent keeps running).
     *
     * Synchronous, and with no composition failure mode of its own — it reads no
     * roster, mounts nothing, and touches no file — which is what lets a child
     * creation window use it: the two in-process subagent drivers compose their
     * children inside a synchronous `setup`. It still rejects a caller error, as
     * the `@throws` below record.
     *
     * A parent that joined no preset — a rosterless deployment — yields no join
     * and no error: there, the model-facing rows sit in the host composition and
     * the child already sees them through the global layer.
     * @param agentCtx - the joining agent's scope context.
     * @param parentCtx - the scope context of the agent whose composition to join.
     * @returns the preset id joined, or undefined when the parent joined none.
     * @throws when `agentCtx` carries no scope, or has already joined a preset.
     */
    composeFrom(agentCtx, parentCtx) {
        const agentKey = scopeOf(agentCtx);
        if (agentKey === undefined) {
            throw new Error('agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset');
        }
        const standing = standingMountFor(parentCtx);
        if (standing === undefined)
            return undefined;
        this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
        return standing.presetId;
    }
    /**
     * The preset one live agent runs on.
     *
     * Read from the live scope chain rather than from the session, so it answers
     * for an agent whose session has not recorded a preset yet — a child agent
     * whose durable header is being built from its parent's composition.
     * @param agentCtx - the agent's scope context.
     * @returns the preset id, or undefined when the agent joined none.
     */
    composedPreset(agentCtx) {
        return standingMountFor(agentCtx)?.presetId;
    }
    /**
     * The roots this roster scans, which is not `config.roots`: it is every
     * configured root in order, then the harness-home user root unless
     * `includeUserRoot` is false. Read this — not the config field — to answer
     * whether a roster is composed at all, so one derivation decides it.
     */
    get roots() {
        return this.resolvedRoots;
    }
    /** Whether this deployment has a root locally authored presets go to. */
    get authorable() {
        return this.resolvedRoots.some(root => root.trust === 'user');
    }
    /**
     * Read one preset's composition text.
     * @param id - the preset id.
     * @returns the composition exactly as stored.
     * @throws when no configured root supplies that id.
     */
    async read(id) {
        return await readComposition(await this.resolve(id));
    }
    /**
     * Create a locally authored preset by copying an existing one whole.
     *
     * Copy is the only authoring write. Composition text never crosses this
     * seam: the source is named by id and its directory is copied as it stands,
     * so the copy is exactly as loadable as its source and authoring grants no
     * capability the roster did not already carry. The copy is NOT mounted to
     * validate — a source that mounts today yields a copy that mounts today.
     * @param from - the preset the copy starts from; shipped presets are the
     * primary source, so any trust is accepted.
     * @param id - the new preset's id, which becomes its directory name.
     * @param name - display name for the copy; absent falls back to the id.
     * @throws when the source is unknown, the id is unusable or already taken,
     * or the deployment configures no writable root.
     */
    async copy(from, id, name) {
        const source = await this.resolve(from);
        // The roster check refuses ids any root supplies — shipped ones included,
        // since a user directory named like a shipped preset is shadowed by it.
        // The disk check inside copyComposition only sees the writable root.
        if ((await this.list()).some(preset => preset.id === id)) {
            throw new PresetExistsError(id);
        }
        await copyComposition(this.resolvedRoots, source, id, name);
        // A settled mount under this id can only be stale (its preset was deleted
        // from disk outside `remove`); the new preset must not inherit it. Every
        // session already joined keeps the generation it runs on regardless.
        this.standing.delete(id);
    }
    /**
     * Delete a locally authored preset.
     * @param id - the preset id.
     * @throws when the preset is unknown or ships with the deployment.
     */
    async remove(id) {
        await deleteComposition(this.resolvedRoots, await this.resolve(id));
        // Sessions on the deleted preset keep their standing mount; only new
        // sessions see the roster without it.
        this.standing.delete(id);
        // Storing a default that does not exist YET is deliberate — the roster is a
        // live directory, so a name absent now may exist by the time a session asks
        // for it, and `resolve` reports it then. A default this call just deleted is
        // not that case: nothing will ever supply it again, and left in place every
        // session created without an explicit pick would fail to start. Clearing it
        // exposes the deployment's own default underneath, which is the layering.
        if (this.settings?.get().default !== id)
            return;
        await this.settingsService?.mutate(settingsNamespace(SETTINGS_NAMESPACE), [{ op: 'unset', path: ['default'] }]);
    }
    /**
     * One agent's instance of a service its preset mounted.
     *
     * A preset publishes services behind `isolate` realms, which are invisible
     * outside the group that declares them — including to the host. This is how a
     * caller holding the agent reads one anyway: a request that is ABOUT a
     * session but arrives from outside it, which is every browser RPC.
     *
     * Read addressing only. A host row that `inject`s a service cannot use this,
     * because injection resolves before any session exists and has no agent to
     * key by; such a service belongs on the host plane instead.
     * @param agent - the agent whose composition to look inside.
     * @param name - the service name as the preset's rows resolve it.
     * @returns the agent's instance, or undefined when its preset mounts none.
     */
    serviceFor(agent, name) {
        return serviceForAgent(this.ctx, agent, name);
    }
    /**
     * Re-link one agent to a different preset's standing composition.
     *
     * Only valid while the agent has produced nothing: swapping tools mid
     * conversation would leave logged tool calls the new composition cannot
     * make. The CALLER owns that check — this method does not read session
     * history.
     *
     * The swap is a parent re-link, not an unmount: standing mounts are shared
     * and permanent, so the old composition stays for its other agents and the
     * new one is ensured BEFORE the link moves. An unknown or unusable preset
     * therefore throws with the agent exactly as it was — there is no torn-down
     * state to restore. The re-link runs through the binding this roster kept
     * from the agent's mount — dsh-scope's only re-link authority. An agent
     * that never composed one has nothing to re-link: the switch is then the
     * agent's first bind, exactly a mount.
     * @param agentCtx - the agent's scope context.
     * @param id - the preset to compose the agent from instead.
     * @returns the preset now installed.
     * @throws when the preset is unknown or its composition is unusable.
     */
    async recompose(agentCtx, id) {
        const agentKey = scopeOf(agentCtx);
        if (agentKey === undefined) {
            throw new Error('agent-presets: refusing to recompose an unscoped context');
        }
        const preset = await this.resolveMountable(id);
        const standing = await this.ensureStanding(preset);
        const binding = this.bindings.get(agentKey);
        if (binding === undefined) {
            this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
        }
        else {
            binding.rebind(standing.key);
        }
        return preset;
    }
    /**
     * The standing scope key of one preset, for a host reader with no agent.
     *
     * A cold transcript read resolves tool presenters against the composition
     * the session recorded, and the standing mount makes that possible without
     * resuming anything: ensuring the mount composes plugins but starts no
     * agent, no session, and no turn.
     * @param id - the preset id, or `undefined` for {@link defaultId}.
     * @returns the standing scope key readers pass as a registry view scope.
     * @throws when the preset is unknown or its composition is unusable.
     */
    async standingKeyFor(id) {
        const preset = await this.resolveMountable(id);
        return (await this.ensureStanding(preset)).key;
    }
    /** Resolve (or create, single-flight) the standing mount of one preset. */
    async ensureStanding(preset) {
        const pending = this.standing.get(preset.id);
        if (pending !== undefined) {
            const mounted = await pending;
            // Files are the only composition editor (authoring is copy/delete), so
            // the stamp is what notices an edit: a changed file starts the next
            // generation here, for this and later sessions. An unreadable stamp
            // serves the current generation — a mount must survive its file
            // disappearing, and failing the session over a stat would not.
            const current = await compositionStamp(preset.path);
            if (current === undefined || sameStamp(mounted.stamp, current))
                return mounted;
            // TODO: reclaim the superseded generation once the last agent joined to
            // it is gone. The subtree is not inert — `dsh-skill-filesystem` watches its
            // roots — and the settings-page authoring flow turns "a composition
            // changed" into a per-save event. This needs a joined-agent count on
            // StandingMount, incremented in `mount`/`composeFrom`/`recompose` and
            // decremented when the agent's scope key dies.
            // Guarded delete: a caller that raced this one may have already started
            // the next generation, and dropping THAT pointer would fork a third.
            if (this.standing.get(preset.id) === pending)
                this.standing.delete(preset.id);
            return this.ensureStanding(preset);
        }
        const created = (async () => {
            const key = { agentPreset: preset.id };
            const scope = createScope(this.selfCtx, key);
            try {
                // Stamped before the file is read: an edit racing the mount makes the
                // stamp stale rather than silently current, so the next session
                // refreshes instead of trusting a composition older than its stamp.
                const stamp = await compositionStamp(preset.path);
                if (stamp === undefined) {
                    throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`);
                }
                await mountPreset(scope.ctx, preset);
                return { key, scope, stamp };
            }
            catch (error) {
                this.standing.delete(preset.id);
                await scope.dispose();
                throw error;
            }
        })();
        this.standing.set(preset.id, created);
        return created;
    }
}
/** Read one composition file's stamp, or undefined when it cannot be statted. */
async function compositionStamp(path) {
    try {
        const { mtimeMs, size } = await stat(path);
        return { mtimeMs, size };
    }
    catch {
        // Deleted, replaced by an unreadable entry, or otherwise unstattable all
        // mean the same to the caller: the file offers no identity to compare.
        return undefined;
    }
}
/** Whether two stamps name the same file state. */
function sameStamp(a, b) {
    return a.mtimeMs === b.mtimeMs && a.size === b.size;
}
export default AgentPresets;
//# sourceMappingURL=index.js.map