@deepseek-ai/dsh-agent-presets 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1130 @@
1
+ import { chmod, cp, readFile, readdir, rm, stat } from "node:fs/promises";
2
+ import { Context, Service } from "@deepseek-ai/cordis";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { bindScopeParent, createScope, scopeOf, scopeParentOf } from "@deepseek-ai/dsh-scope";
5
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import yaml, { load } from "js-yaml";
8
+ import { Include, entryListSchema } from "@deepseek-ai/cordis-plugin-include";
9
+ import { expandHomePath } from "@deepseek-ai/dsh-paths";
10
+ import { writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
11
+ import { pathToFileURL } from "node:url";
12
+ //#region lib/types/metadata.js
13
+ /**
14
+ * A preset's display metadata: the name and description a picker shows.
15
+ *
16
+ * It lives in its own file because the composition is a top-level list of
17
+ * plugin rows — YAML cannot carry sibling keys beside it, and faking a
18
+ * metadata row would hand the Loader something to load. Keeping it separate
19
+ * also keeps the composition exactly what its name says: a Cordis file the
20
+ * loader owns and the cordis preset can author.
21
+ *
22
+ * The file carries display text ONLY. `id` is the directory name and `trust`
23
+ * comes from the root a preset was discovered under, so neither is writable
24
+ * here — otherwise a locally authored preset could claim to be a shipped one.
25
+ *
26
+ * Every read failure degrades to no metadata. A preset whose display text is
27
+ * missing, malformed, or unreadable still mounts: presentation is not a
28
+ * capability, and a broken name must never become an agent that cannot start.
29
+ * @module @deepseek-ai/dsh-agent-presets/metadata
30
+ */
31
+ /** The optional display-metadata file beside a preset's composition. */
32
+ const METADATA_FILE = "preset.yml";
33
+ /** A non-empty trimmed string, or undefined for anything else. */
34
+ function text(value) {
35
+ if (typeof value !== "string") return void 0;
36
+ const trimmed = value.trim();
37
+ return trimmed === "" ? void 0 : trimmed;
38
+ }
39
+ /**
40
+ * Read one preset directory's display metadata.
41
+ *
42
+ * Absent, unparsable, and wrongly-shaped files are all the same answer —
43
+ * empty metadata — because the caller renders a picker, not a diagnostic.
44
+ * @param directory - the preset directory.
45
+ * @returns the display text the preset published, possibly empty.
46
+ */
47
+ async function readPresetMetadata(directory) {
48
+ let raw;
49
+ try {
50
+ raw = await readFile(join(directory, METADATA_FILE), "utf8");
51
+ } catch {
52
+ return {};
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = yaml.load(raw);
57
+ } catch {
58
+ return {};
59
+ }
60
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
61
+ const record = parsed;
62
+ const name = text(record.name);
63
+ const description = text(record.description);
64
+ const order = typeof record.order === "number" && Number.isFinite(record.order) ? record.order : void 0;
65
+ return {
66
+ ...name === void 0 ? {} : { name },
67
+ ...description === void 0 ? {} : { description },
68
+ ...order === void 0 ? {} : { order }
69
+ };
70
+ }
71
+ /**
72
+ * Render display metadata as the file's contents.
73
+ *
74
+ * Absent fields are omitted rather than written empty, so a preset with no
75
+ * description does not ship a key that reads as an intentional blank.
76
+ * @param metadata - the display text to store.
77
+ * @returns the YAML document, or undefined when there is nothing to store.
78
+ */
79
+ function renderPresetMetadata(metadata) {
80
+ const name = text(metadata.name);
81
+ const description = text(metadata.description);
82
+ const { order } = metadata;
83
+ if (name === void 0 && description === void 0 && order === void 0) return void 0;
84
+ return yaml.dump({
85
+ ...name === void 0 ? {} : { name },
86
+ ...description === void 0 ? {} : { description },
87
+ ...order === void 0 ? {} : { order }
88
+ }, { lineWidth: -1 });
89
+ }
90
+ //#endregion
91
+ //#region lib/types/types.js
92
+ /** Agent-preset vocabulary shared by discovery, mounting, and consumers. @module @deepseek-ai/dsh-agent-presets/types */
93
+ /**
94
+ * Ids a preset directory may use.
95
+ *
96
+ * The id becomes a path segment, so this is a containment boundary rather than
97
+ * a style rule: `..`, a separator, or an absolute-looking name would place the
98
+ * composition outside the root the deployment authorised. Discovery shares it:
99
+ * a directory whose name no copy could ever claim is not a preset slot.
100
+ */
101
+ const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
102
+ /**
103
+ * No configured root supplies the requested preset.
104
+ *
105
+ * Separate from a mount failure because the two mean different things to a
106
+ * caller: an unknown id is a bad request, while an unusable composition is a
107
+ * broken preset the deployment must fix.
108
+ */
109
+ var UnknownPresetError = class extends Error {
110
+ presetId;
111
+ available;
112
+ constructor(presetId, available) {
113
+ super(`agent-presets: preset "${presetId}" not found (available: ${available.join(", ") || "none"})`);
114
+ this.presetId = presetId;
115
+ this.available = available;
116
+ }
117
+ };
118
+ /** A preset exists but its composition cannot be installed. */
119
+ var PresetMountError = class extends Error {
120
+ presetId;
121
+ reason;
122
+ constructor(presetId, reason, options) {
123
+ super(`agent-presets: preset "${presetId}" failed to mount: ${reason}`, options);
124
+ this.presetId = presetId;
125
+ this.reason = reason;
126
+ }
127
+ };
128
+ //#endregion
129
+ //#region lib/types/discovery.js
130
+ /**
131
+ * Filesystem discovery of agent presets. A preset is a directory holding
132
+ * {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying
133
+ * its display text; the directory name is the preset id. Discovery
134
+ * re-reads the roots on every call so a preset authored while the process is
135
+ * running is visible without a restart.
136
+ *
137
+ * Discovery also owns preset HEALTH: a directory whose composition is
138
+ * missing or unloadable is reported as a broken roster row rather than
139
+ * skipped. A skipped directory would still occupy its id on disk — the copy
140
+ * path refuses the name while no surface shows anything to delete — and a
141
+ * malformed composition would otherwise read as an ordinary preset until the
142
+ * first session fails to mount it.
143
+ * @module @deepseek-ai/dsh-agent-presets/discovery
144
+ */
145
+ /** The composition file that makes a directory a preset. */
146
+ const COMPOSITION_FILE = "agent.cordis.yml";
147
+ /**
148
+ * Why `rows` cannot be an entry list, or undefined when it can.
149
+ *
150
+ * A shallow shape check, deliberately short of the loader's work: it does not
151
+ * resolve plugin names or apply configs. What it catches is the hand-edit
152
+ * that produces a file the loader cannot even begin with — and it must accept
153
+ * everything the loader accepts, which is why rows are only required to be
154
+ * maps carrying a plugin `name` (groups recurse into their own lists).
155
+ * @param rows - the parsed composition document.
156
+ * @param at - row-path prefix for nested diagnostics, empty at the top level.
157
+ * @returns one human-readable reason, or undefined when the shape holds.
158
+ */
159
+ function entryListProblem(rows, at = "") {
160
+ if (!Array.isArray(rows)) return at === "" ? "the composition must be a top-level list of plugin rows" : `group ${at} must hold a list of plugin rows`;
161
+ for (const [index, row] of rows.entries()) {
162
+ const label = at === "" ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`;
163
+ if (typeof row !== "object" || row === null || Array.isArray(row)) return `${label} is not a plugin row (expected a map with a "name")`;
164
+ const { name, group, config } = row;
165
+ if (typeof name !== "string" || name === "") return `${label} names no plugin (a "name" string is required)`;
166
+ if (group === true) {
167
+ const nested = entryListProblem(config, label);
168
+ if (nested !== void 0) return nested;
169
+ }
170
+ }
171
+ }
172
+ /**
173
+ * Why the composition at `path` cannot mount, or undefined when it looks
174
+ * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
175
+ * the one carrying `!!js`), so health can never call a composition broken
176
+ * that the loader would accept.
177
+ * @param path - absolute path of the composition file.
178
+ * @returns one human-readable reason, or undefined when the file is loadable.
179
+ */
180
+ async function compositionProblem(path) {
181
+ let content;
182
+ try {
183
+ content = await readFile(path, "utf8");
184
+ } catch {
185
+ return `the composition file ${COMPOSITION_FILE} cannot be read`;
186
+ }
187
+ let rows;
188
+ try {
189
+ rows = load(content, { schema: entryListSchema });
190
+ } catch (error) {
191
+ return `the composition is not valid YAML: ${(error instanceof Error ? error.message : String(error)).replace(/\n[\s\S]*$/, "")}`;
192
+ }
193
+ return entryListProblem(rows);
194
+ }
195
+ /**
196
+ * Whether `path` names an existing regular file.
197
+ * @param path - absolute path to test.
198
+ * @returns true when the path resolves to a file.
199
+ */
200
+ async function isFile(path) {
201
+ try {
202
+ return (await stat(path)).isFile();
203
+ } catch {
204
+ return false;
205
+ }
206
+ }
207
+ /**
208
+ * Scan one root for preset directories.
209
+ *
210
+ * An absent root yields no presets rather than throwing: the user root does
211
+ * not exist until the first locally authored preset, and naming a default
212
+ * that no root supplies already fails loud at resolution.
213
+ *
214
+ * Every directory whose name is a usable preset id is a roster row — broken
215
+ * when its composition is missing or unloadable. A directory named outside
216
+ * {@link PRESET_ID} is skipped instead: no copy could ever claim that name,
217
+ * so it blocks nothing, and reporting `.DS_Store`-grade residue as broken
218
+ * presets would teach users to ignore the marker.
219
+ * @param root - the directory and the trust its presets inherit.
220
+ * @returns the root's presets ordered by id.
221
+ */
222
+ async function scanRoot(root) {
223
+ const dir = resolve(expandHomePath(root.path));
224
+ let children;
225
+ try {
226
+ children = await readdir(dir, { withFileTypes: true });
227
+ } catch (error) {
228
+ if (error.code === "ENOENT") return [];
229
+ throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error });
230
+ }
231
+ const found = [];
232
+ for (const child of children) {
233
+ if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue;
234
+ const directory = join(dir, child.name);
235
+ const path = join(directory, COMPOSITION_FILE);
236
+ const broken = await isFile(path) ? await compositionProblem(path) : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`;
237
+ const metadata = await readPresetMetadata(directory);
238
+ found.push({
239
+ id: child.name,
240
+ trust: root.trust,
241
+ path,
242
+ ...metadata,
243
+ ...broken === void 0 ? {} : { broken }
244
+ });
245
+ }
246
+ return found.sort((left, right) => {
247
+ const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY);
248
+ return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder;
249
+ });
250
+ }
251
+ /**
252
+ * Scan every root in precedence order.
253
+ * @param roots - roots in precedence order; an earlier root wins a duplicate id.
254
+ * @returns every discovered preset, first-root-wins per id.
255
+ */
256
+ async function discoverPresets(roots) {
257
+ const byId = /* @__PURE__ */ new Map();
258
+ for (const root of roots) for (const preset of await scanRoot(root)) {
259
+ if (byId.has(preset.id)) continue;
260
+ byId.set(preset.id, preset);
261
+ }
262
+ return [...byId.values()];
263
+ }
264
+ //#endregion
265
+ //#region lib/types/authoring.js
266
+ /**
267
+ * Copying, reading, and deleting locally authored presets.
268
+ *
269
+ * Authoring is confined to a `user` root: the shipped `.system` set is part of
270
+ * the deployment, and letting a browser rewrite it would turn "reset to a known
271
+ * preset" into something the same caller could have broken first.
272
+ *
273
+ * The only authoring write is a whole-directory copy of an existing preset.
274
+ * No caller supplies composition text: the inputs are ids the host resolves
275
+ * against its own roots plus an optional display name, so authoring grants no
276
+ * capability the copied preset did not already carry.
277
+ * @module @deepseek-ai/dsh-agent-presets/authoring
278
+ */
279
+ /** A preset id that cannot be used as a directory name under a root. */
280
+ var InvalidPresetIdError = class extends Error {
281
+ presetId;
282
+ constructor(presetId) {
283
+ super(`agent-presets: preset id ${JSON.stringify(presetId)} must match ${String(PRESET_ID)} — the id is a directory name, so anything else could escape the preset root`);
284
+ this.presetId = presetId;
285
+ }
286
+ };
287
+ /** A copy target that is already occupied — a copy never overwrites. */
288
+ var PresetExistsError = class extends Error {
289
+ presetId;
290
+ constructor(presetId) {
291
+ super(`agent-presets: preset "${presetId}" already exists — a copy never overwrites; delete the existing preset first or choose another id`);
292
+ this.presetId = presetId;
293
+ }
294
+ };
295
+ /** Authoring was attempted where the deployment allows none. */
296
+ var PresetNotWritableError = class extends Error {
297
+ presetId;
298
+ constructor(presetId, reason) {
299
+ super(`agent-presets: preset "${presetId}" cannot be written: ${reason}`);
300
+ this.presetId = presetId;
301
+ }
302
+ };
303
+ /**
304
+ * The root locally authored presets are written to.
305
+ * @param roots - the configured roots in precedence order.
306
+ * @returns the absolute path of the first `user` root.
307
+ * @throws when the deployment configured no writable root.
308
+ */
309
+ function writableRoot(roots) {
310
+ const root = roots.find((candidate) => candidate.trust === "user");
311
+ if (root === void 0) throw new PresetNotWritableError("", "this deployment configures no user-writable preset root");
312
+ return resolve(expandHomePath(root.path));
313
+ }
314
+ /**
315
+ * Read one preset's composition text.
316
+ * @param preset - the resolved preset.
317
+ * @returns the file's contents.
318
+ */
319
+ async function readComposition(preset) {
320
+ return await readFile(preset.path, "utf8");
321
+ }
322
+ /** Whether anything occupies the path (cp's own errorOnExist backstops races). */
323
+ async function occupied(path) {
324
+ let present = true;
325
+ try {
326
+ await stat(path);
327
+ } catch {
328
+ present = false;
329
+ }
330
+ return present;
331
+ }
332
+ /**
333
+ * Re-tighten a copied tree to owner-only. A shipped preset is world-readable
334
+ * in its install and `cp` preserves that; the copy carries the same weight as
335
+ * the settings document beside it, so group/other access is stripped. A
336
+ * file's owner-execute bit survives — a preset may ship runnable helpers.
337
+ */
338
+ async function tightenModes(dir) {
339
+ await chmod(dir, 448);
340
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
341
+ const target = join(dir, entry.name);
342
+ if (entry.isDirectory()) await tightenModes(target);
343
+ else
344
+ /* v8 ignore next -- Windows exposes no POSIX owner-execute bit; the POSIX lane covers both file modes. */
345
+ await chmod(target, ((await stat(target)).mode & 64) === 0 ? 384 : 448);
346
+ }
347
+ }
348
+ /**
349
+ * Create a preset by copying an existing one's whole directory.
350
+ *
351
+ * The copy carries everything the source directory holds — composition,
352
+ * metadata, skill directories, assets — because a preset is its directory,
353
+ * not one file. Symlinks are dereferenced so the copy is self-contained
354
+ * rather than a set of links back into the install it was copied from.
355
+ *
356
+ * The copied metadata is then rewritten: the source's description is kept
357
+ * (the file is the author's to edit afterwards), but its name and roster
358
+ * `order` are not — a copy presenting itself identically to its source, or
359
+ * sorted into the shipped set's declared order, would make the roster stop
360
+ * distinguishing them. With no name given and no description to keep, the
361
+ * file is removed so the copy publishes nothing rather than a blank.
362
+ * @param roots - the configured roots; the first `user` one receives the copy.
363
+ * @param source - the resolved preset the copy starts from.
364
+ * @param id - the new preset's id, which becomes its directory name.
365
+ * @param name - display name for the copy; omitted falls back to the id.
366
+ * @returns the absolute path of the new preset directory.
367
+ * @throws when the id is unusable or already occupied on disk, or the
368
+ * deployment configures no writable root.
369
+ */
370
+ async function copyComposition(roots, source, id, name) {
371
+ if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id);
372
+ const dir = join(writableRoot(roots), id);
373
+ if (await occupied(dir)) throw new PresetExistsError(id);
374
+ try {
375
+ await cp(dirname(source.path), dir, {
376
+ recursive: true,
377
+ dereference: true,
378
+ force: false,
379
+ errorOnExist: true
380
+ });
381
+ await tightenModes(dir);
382
+ const rendered = renderPresetMetadata({
383
+ ...name === void 0 ? {} : { name },
384
+ ...source.description === void 0 ? {} : { description: source.description }
385
+ });
386
+ const metadataPath = join(dir, METADATA_FILE);
387
+ if (rendered === void 0) await rm(metadataPath, { force: true });
388
+ else await writeFileAtomic(metadataPath, rendered, {
389
+ mode: 384,
390
+ dirMode: 448
391
+ });
392
+ } catch (error) {
393
+ await rm(dir, {
394
+ recursive: true,
395
+ force: true
396
+ });
397
+ throw error;
398
+ }
399
+ return dir;
400
+ }
401
+ /**
402
+ * Delete a locally authored preset.
403
+ *
404
+ * A shipped preset is refused: it belongs to the deployment. A preset a live
405
+ * session mounted is NOT refused — the composition was read at creation and is
406
+ * never re-read, so that session keeps running exactly as it was.
407
+ * @param roots - the configured roots.
408
+ * @param preset - the resolved preset to remove.
409
+ * @throws when the preset ships with the deployment or lies outside the writable root.
410
+ */
411
+ async function deleteComposition(roots, preset) {
412
+ if (preset.trust !== "user") throw new PresetNotWritableError(preset.id, "it ships with the deployment");
413
+ const dir = join(writableRoot(roots), preset.id);
414
+ if (!isAbsolute(preset.path) || !preset.path.startsWith(dir)) throw new PresetNotWritableError(preset.id, "it does not live under the writable preset root");
415
+ await rm(dir, {
416
+ recursive: true,
417
+ force: true
418
+ });
419
+ }
420
+ //#endregion
421
+ //#region lib/types/mount.js
422
+ /**
423
+ * Mount one preset composition under an agent's scope context, then prove the
424
+ * result is usable before the agent is published.
425
+ *
426
+ * The scope context is what makes the composition per-session: entry contexts
427
+ * chain to the context the subtree was plugged into, so every `ctx.tools`
428
+ * and `ctx.systemPrompt` registration inside the preset files into that
429
+ * agent's layer and unwinds with it. Two guards make that safe. A row that
430
+ * never reached a usable state is rejected, because a directly-plugged subtree
431
+ * is absent from `ctx.loader.entries()` and no boot audit covers it. A row that
432
+ * published a service into the ROOT realm is rejected, because such a service
433
+ * is process-global rather than per-session and the second session mounting the
434
+ * same preset collides with the first.
435
+ * @module @deepseek-ai/dsh-agent-presets/mount
436
+ */
437
+ /**
438
+ * Subtrees captured by config identity. A subtree plugged directly (rather than
439
+ * created as a loader entry) never links itself to an `Entry`, so this is the
440
+ * only handle to the rows it created; config objects are minted per mount, so
441
+ * concurrent mounts cannot collide.
442
+ */
443
+ const mounted = /* @__PURE__ */ new WeakMap();
444
+ /**
445
+ * The base URL bare specifiers resolve against, per pending mount, keyed by the
446
+ * same config object. Recorded before the subtree is plugged, because `Include`
447
+ * rewrites its own context's `baseUrl` to the composition's directory and the
448
+ * pre-mount value is the only handle on where the harness itself lives.
449
+ */
450
+ const harnessBase = /* @__PURE__ */ new WeakMap();
451
+ /**
452
+ * Include subclass that publishes its tree and fiber for the audit, and never
453
+ * writes to the file it read.
454
+ */
455
+ var PresetTree = class extends Include {
456
+ constructor(ctx, config) {
457
+ super(ctx, config);
458
+ mounted.set(config, {
459
+ tree: this,
460
+ fiber: ctx.fiber
461
+ });
462
+ }
463
+ /**
464
+ * Resolve a bare specifier from the harness rather than from the preset.
465
+ *
466
+ * `EntryTree.import()` resolves against the tree's own `baseUrl`, which
467
+ * `Include` sets to the composition's directory. That is right for a
468
+ * relative specifier — a preset's own files travel with it — and wrong for
469
+ * a package name: a locally authored preset lives under the user's home,
470
+ * where Node's upward `node_modules` walk never reaches the harness's own
471
+ * dependencies, so every `@deepseek-ai/dsh-*` row would fail to import. The
472
+ * mount records the host composition's base instead, which is inside the
473
+ * installed harness, and bare names resolve from there. An absolute
474
+ * filesystem path names neither base and becomes a file URL before Node's
475
+ * ESM loader receives it, which is required for drive-letter paths on
476
+ * Windows.
477
+ * @param name - the module specifier from the row.
478
+ * @param getOuterStack - the loader's stack composer for import diagnostics.
479
+ * @returns the imported module, or the `cordis:` builtin.
480
+ */
481
+ import(name, getOuterStack) {
482
+ const specifier = isAbsolute(name) ? pathToFileURL(name).href : name;
483
+ const base = harnessBase.get(this.config);
484
+ /* v8 ignore next -- every PresetTree is constructed by `mountPreset`, which records the base first */
485
+ if (base === void 0) return super.import(specifier, getOuterStack);
486
+ if (name.startsWith(".") || name.startsWith("cordis:")) return super.import(name, getOuterStack);
487
+ const internal = this.ctx.loader.internal;
488
+ /* v8 ignore next -- Node always supplies the internal module loader; the branch keeps a
489
+ hypothetical embedder from losing the row's name in a resolution error. */
490
+ if (internal === void 0) return super.import(specifier, getOuterStack);
491
+ return internal.import(specifier, base, {});
492
+ }
493
+ /**
494
+ * A preset is an input, never a persistence target.
495
+ *
496
+ * The Loader writes a tree back through this method whenever it decides the
497
+ * config changed — a plugin self-disposing is enough, and tearing an agent
498
+ * down disposes its whole subtree. Inherited, that rewrites the preset file
499
+ * with whatever the dying tree held, which in practice means truncating a
500
+ * shipped composition to `[]` the first time a session ends. Persisting a
501
+ * preset is also meaningless: nothing here is user state, and the same file
502
+ * backs every session that names it.
503
+ *
504
+ * Dropping the write drops the `loader/config-update` the inherited method
505
+ * emits with it. Nothing observes one for a preset subtree today, and a
506
+ * future "edit your preset while it runs" flow needs a deliberate
507
+ * persistence path rather than this method's return.
508
+ */
509
+ write() {}
510
+ };
511
+ const mounts = /* @__PURE__ */ new Set();
512
+ /**
513
+ * Drop every record whose subtree is gone.
514
+ *
515
+ * Records are pruned by observation rather than through a disposal hook
516
+ * because a subtree can be torn down by its owning agent, by a failed mount, or
517
+ * by the whole tree unloading, and a cleared `uid` is what all three share.
518
+ *
519
+ * Pruning therefore has to happen on a path this module owns. Reading is one
520
+ * such path, but not a reliable one: the only production reader is the
521
+ * invariant companion's service listener, and `dsh-invariants` is a
522
+ * development composition — a shipped host never loads it. Mounting is the
523
+ * other, and it is the one every session takes, which bounds the set at one
524
+ * generation of dead records rather than one per session ever composed. Each
525
+ * record would otherwise retain its whole disposed subtree: the fiber holds
526
+ * its config, and that config is the key its `EntryTree` is stored under.
527
+ */
528
+ function pruneDisposedMounts() {
529
+ for (const mount of mounts) if (mount.fiber.uid === null) mounts.delete(mount);
530
+ }
531
+ /**
532
+ * Every preset composition still installed, pruning fibers disposed since the
533
+ * last read.
534
+ * @returns the live mounts.
535
+ */
536
+ function livePresetMounts() {
537
+ pruneDisposedMounts();
538
+ return [...mounts];
539
+ }
540
+ /**
541
+ * Whether `fiber` is `root` itself or is mounted anywhere inside its subtree.
542
+ *
543
+ * Membership is object identity. `uid` looks like a cheaper key but is a
544
+ * per-registry counter, so fibers in two different roots collide on it and a
545
+ * subtree in one runtime would be blamed for a service published in another.
546
+ * @param fiber - the fiber to locate.
547
+ * @param root - the subtree root to test membership against.
548
+ * @returns true when `fiber` belongs to `root`'s subtree.
549
+ */
550
+ function withinFiber(fiber, root) {
551
+ let current = fiber;
552
+ while (true) {
553
+ if (current === root) return true;
554
+ const parent = current.parent.fiber;
555
+ if (parent === current) return false;
556
+ current = parent;
557
+ }
558
+ }
559
+ /**
560
+ * Service names the mounted subtree published into the root realm.
561
+ *
562
+ * A provider without an `isolate` realm stores its implementation under the
563
+ * root's symbol for that name, which is exactly the comparison below; a
564
+ * provider inside an `isolate` realm stores under a realm-private symbol and
565
+ * is correctly absent here.
566
+ * @param ctx - any context of the runtime whose service store is inspected.
567
+ * @param mount - the mounted subtree's fiber.
568
+ * @returns the leaked service names in lexical order.
569
+ */
570
+ function leakedServices(ctx, mount) {
571
+ const store = ctx.reflect.store;
572
+ const rootIsolate = ctx.root[Context.isolate];
573
+ const leaked = [];
574
+ for (const key of Object.getOwnPropertySymbols(store)) {
575
+ const impl = store[key];
576
+ /* v8 ignore next -- cordis deletes a store slot on disposal rather than
577
+ clearing it, so an own symbol always resolves; the guard exists only
578
+ because the store's index signature is optional. */
579
+ if (impl === void 0) continue;
580
+ if (!withinFiber(impl.fiber, mount)) continue;
581
+ if (rootIsolate[impl.name] === key) leaked.push(impl.name);
582
+ }
583
+ return leaked.sort((left, right) => left.localeCompare(right));
584
+ }
585
+ /**
586
+ * The standing composition one agent is joined to.
587
+ *
588
+ * The agent's own key is parented to its preset's standing key, so the mount
589
+ * is found by matching that parent rather than by walking up from the agent —
590
+ * the mount is not under the agent's fiber. An agent that joined no preset —
591
+ * a deployment composing no roster, or a child agent before its join — has no
592
+ * parent link and resolves to undefined.
593
+ * @param agentCtx - the agent's scope context.
594
+ * @returns the mount the agent joined, or undefined when it joined none.
595
+ */
596
+ function standingMountFor(agentCtx) {
597
+ const agentKey = scopeOf(agentCtx);
598
+ if (agentKey === void 0) return void 0;
599
+ const standingKey = scopeParentOf(agentKey);
600
+ if (standingKey === void 0) return void 0;
601
+ return livePresetMounts().find((candidate) => candidate.key === standingKey);
602
+ }
603
+ /**
604
+ * One agent's instance of a service its preset mounted.
605
+ *
606
+ * A preset publishes a service behind an `isolate` realm so two sessions
607
+ * cannot collide, and an entry-local realm is invisible to everything outside
608
+ * the group — including the agent's own scope context and the host. That is
609
+ * right for the rows inside the group and wrong for one caller: a request that
610
+ * is ABOUT a session but arrives from outside it, which is every browser RPC
611
+ * the api-proxy serves.
612
+ *
613
+ * Ownership is the same relation {@link leakedServices} reads, inverted: there
614
+ * it names implementations a subtree published into the ROOT realm, here it
615
+ * names the one this subtree published anywhere. Fiber membership is object
616
+ * identity for the reason stated on {@link withinFiber}.
617
+ *
618
+ * This is READ addressing for a caller that already holds the agent. It is not
619
+ * a general host handle on a session's internals: a host row that `inject`s a
620
+ * service cannot use it, because injection resolves before any session exists
621
+ * and has no agent to key by — such a service belongs on the host plane.
622
+ * @param ctx - any context of the runtime whose service store is inspected.
623
+ * @param agent - the agent whose mounted composition to look inside.
624
+ * @param name - the service name as the preset's rows resolve it.
625
+ * @returns the agent's instance, or undefined when its preset mounts none.
626
+ */
627
+ function serviceForAgent(ctx, agent, name) {
628
+ const mount = standingMountFor(agent.ctx);
629
+ if (mount === void 0) return void 0;
630
+ const store = ctx.reflect.store;
631
+ for (const key of Object.getOwnPropertySymbols(store)) {
632
+ const impl = store[key];
633
+ /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
634
+ if (impl === void 0) continue;
635
+ if (impl.name !== name) continue;
636
+ if (withinFiber(impl.fiber, mount.fiber)) return impl.value;
637
+ }
638
+ }
639
+ /**
640
+ * Rows that did not reach a usable state, each rendered as one diagnostic line.
641
+ *
642
+ * A row whose module failed to import or whose plugin threw already rejects the
643
+ * mount through the loader; what remains observable here is a row still waiting
644
+ * for a service the composition never supplies.
645
+ * @param tree - the mounted subtree.
646
+ * @returns one line per unusable row, empty when every enabled row is usable.
647
+ */
648
+ function inactiveRows(tree) {
649
+ const lines = [];
650
+ for (const entry of tree.entries()) {
651
+ if (entry.disabled) continue;
652
+ const fiber = entry.fiber;
653
+ /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
654
+ so a settled tree never holds an enabled fiber-less entry; the branch exists
655
+ only because `Entry.fiber` is declared optional. */
656
+ if (fiber === void 0) {
657
+ lines.push(`${entry.options.id} (${entry.options.name}): never started`);
658
+ continue;
659
+ }
660
+ const missing = Object.keys(fiber.inject).filter((name) => fiber.ctx.get(name) === void 0);
661
+ if (missing.length > 0) lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(", ")}`);
662
+ }
663
+ return lines;
664
+ }
665
+ /**
666
+ * The reportable text of a mount failure.
667
+ *
668
+ * The loader reports several failed rows as one `AggregateError`, whose own
669
+ * message names none of them; without flattening, a composition that fails on
670
+ * two rows says only "loader entries failed to apply" and the operator has
671
+ * nothing to act on.
672
+ * @param error - the value the mount rejected with.
673
+ * @returns a single-line-per-cause description.
674
+ */
675
+ function mountDetail(error) {
676
+ /* v8 ignore next -- every path into the mount's catch throws an Error: the loader
677
+ wraps a row's thrown value before it propagates, and this module's own
678
+ rejections are Errors. The fallback keeps a hostile value readable. */
679
+ if (!(error instanceof Error)) return String(error);
680
+ if (!(error instanceof AggregateError)) return error.message;
681
+ return [error.message, ...error.errors.map((cause) => `- ${mountDetail(cause)}`)].join("\n");
682
+ }
683
+ /**
684
+ * Mount `preset` under `agentCtx` and return only once every row is usable.
685
+ *
686
+ * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and
687
+ * the caller receives no disposer. A rejection leaves nothing mounted.
688
+ * @param agentCtx - the agent's scope context, from the agent factory's `setup`.
689
+ * @param preset - the resolved preset to compose the agent from.
690
+ * @throws when `agentCtx` carries no scope, a row is unusable, or a row
691
+ * published a service into the root realm.
692
+ */
693
+ async function mountPreset(agentCtx, preset) {
694
+ if (scopeOf(agentCtx) === void 0) throw new Error(`agent-presets: refusing to mount preset "${preset.id}" into an unscoped context; its registrations would apply to every agent in the process`);
695
+ const config = { path: pathToFileURL(preset.path).href };
696
+ /* v8 ignore next -- the Loader sets `baseUrl` on the root before any scoped context derives from it */
697
+ if (agentCtx.baseUrl !== void 0) harnessBase.set(config, agentCtx.baseUrl);
698
+ pruneDisposedMounts();
699
+ const handle = agentCtx.plugin(PresetTree, config);
700
+ try {
701
+ await handle.await();
702
+ const subtree = mounted.get(config);
703
+ /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
704
+ if (subtree === void 0) throw new Error("mounted subtree did not publish its entry tree");
705
+ const { tree, fiber } = subtree;
706
+ const unusable = inactiveRows(tree);
707
+ if (unusable.length > 0) throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join("\n")}`);
708
+ const leaked = leakedServices(agentCtx, fiber);
709
+ if (leaked.length > 0) throw new Error(`row(s) published process-global service(s) [${leaked.join(", ")}]; a preset service must sit behind an \`isolate\` realm or move to the host composition`);
710
+ mounts.add({
711
+ presetId: preset.id,
712
+ fiber,
713
+ key: scopeOf(agentCtx)
714
+ });
715
+ } catch (error) {
716
+ try {
717
+ await handle.dispose();
718
+ } catch {}
719
+ throw new PresetMountError(preset.id, `${mountDetail(error)} (${preset.path})`, { cause: error });
720
+ }
721
+ }
722
+ //#endregion
723
+ //#region lib/types/session.js
724
+ /**
725
+ * The session-log record of which preset a session actually runs.
726
+ *
727
+ * The creation header names the preset a session STARTED with, and it is
728
+ * deep-frozen because that is a creation fact. A session may still change
729
+ * preset while it is blank, and the effect of that change outlives the blank
730
+ * window: the first turn — and every turn after it — runs under the newly
731
+ * mounted composition. Recording the change is what keeps the log honest, and
732
+ * it is required outright by the repo's model-visible ⟺ logged rule, since the
733
+ * preset decides the tool schemas and prompt sections the model sees.
734
+ *
735
+ * Reconstruction reads {@link resolveSessionPreset}, never the header alone.
736
+ * @module @deepseek-ai/dsh-agent-presets/session
737
+ */
738
+ /**
739
+ * The preset a session actually runs, newest selection winning.
740
+ *
741
+ * The header supplies the creation-time value; every later selection is a
742
+ * logged event, so the last one is the answer. Reading the header alone
743
+ * rebuilds a switched session under the composition it was created with, not
744
+ * the one its history was produced under.
745
+ * @param session - the session's header and event log.
746
+ * @returns the preset id, or `undefined` when the deployment composes none.
747
+ */
748
+ function resolveSessionPreset(session) {
749
+ for (let index = session.events.length - 1; index >= 0; index -= 1) {
750
+ const event = session.events[index];
751
+ if (event?.type === "agent-preset/selected") return event.data.agentPreset;
752
+ }
753
+ return session.header.agentPreset;
754
+ }
755
+ //#endregion
756
+ //#region lib/types/index.js
757
+ /**
758
+ * Agent presets: each session composes its model-facing plugin set from one
759
+ * preset `cordis.yml`, mounted ONCE per preset under a standing scope and
760
+ * joined by every agent that names it.
761
+ *
762
+ * The standing mount is what makes a preset one composition rather than one
763
+ * per session: its plugin instances, tool registrations, prompt sections, and
764
+ * projection units exist exactly once, keyed per session inside the plugins
765
+ * themselves (they predate presets and were written for a shared world). An
766
+ * agent joins by having its scope key parented to the mount's
767
+ * ({@link bindScopeParent}), which makes the mount's registrations visible to
768
+ * that agent's views and the mount's listeners receive that agent's events —
769
+ * and a host reader with no agent at all (a cold transcript read) resolves
770
+ * the same standing registrations by preset id.
771
+ *
772
+ * This package owns the preset vocabulary, filesystem discovery, and the
773
+ * guarded standing mount. It does not decide when an agent is created — the
774
+ * agent factory's `setup(agentCtx)` hook is the one supported call site,
775
+ * because only there is the join installed while the agent is still
776
+ * unpublished, so a rejected composition rolls the whole creation back.
777
+ * @module @deepseek-ai/dsh-agent-presets
778
+ */
779
+ /** Settings namespace carrying the user's chosen default preset. */
780
+ const SETTINGS_NAMESPACE = "agent-presets";
781
+ /** Runtime schema for the user-writable slice. */
782
+ const AgentPresetSettingsSchema = z.object({ default: z.string() });
783
+ /**
784
+ * Registry over the deployment's agent presets.
785
+ *
786
+ * Discovery is unmemoized: `list()` and `resolve()` re-read the roots on every
787
+ * call so a preset authored while the process runs is visible immediately,
788
+ * and a preset deleted underneath a picker disappears from the next read.
789
+ */
790
+ var AgentPresets = class extends Service {
791
+ config;
792
+ static inject = ["loader"];
793
+ /** Runtime schema for the preset roster. */
794
+ static Config = z.object({
795
+ default: z.string().required(),
796
+ roots: z.array(z.object({
797
+ path: z.string().required(),
798
+ trust: z.union(["system", "user"]).default("user")
799
+ })).default([])
800
+ });
801
+ /**
802
+ * The user layer over `config.default`, present only while a settings
803
+ * provider is composed. Held rather than snapshotted so a hot-reloaded
804
+ * document takes effect without a restart.
805
+ */
806
+ settings;
807
+ /**
808
+ * The settings service behind {@link settings}, held for the one write this
809
+ * service makes: clearing a user default it has just deleted.
810
+ */
811
+ settingsService;
812
+ /**
813
+ * The service's own untraced context. Methods invoked through the traceable
814
+ * proxy see `this.ctx` rebound to the CALLER's context, which carries a
815
+ * shadow; a subtree minted from it resolves every service through that
816
+ * shadow's fiber instead of each entry's own inject store, so preset rows
817
+ * would fail on the very services they declare. Standing mounts must hang
818
+ * off the untraced original (the `tasks-local` selfCtx precedent).
819
+ */
820
+ selfCtx;
821
+ constructor(ctx, config) {
822
+ super(ctx, "agentPresets");
823
+ this.config = config;
824
+ this.selfCtx = ctx;
825
+ ctx.inject(["settings"], (settingsCtx) => {
826
+ this.settings = settingsCtx.settings.register(settingsNamespace(SETTINGS_NAMESPACE), AgentPresetSettingsSchema, { base: { default: config.default } });
827
+ this.settingsService = settingsCtx.settings;
828
+ settingsCtx.effect(() => () => {
829
+ this.settings = void 0;
830
+ this.settingsService = void 0;
831
+ }, "agentPresets.settings()");
832
+ });
833
+ }
834
+ /**
835
+ * The preset id mounted when a caller names none.
836
+ *
837
+ * Read per call rather than cached: the settings document is hot-reloaded, so
838
+ * changing the default takes effect on the next session created and leaves
839
+ * every running session on the preset it was composed from.
840
+ */
841
+ get defaultId() {
842
+ return this.settings?.get().default ?? this.config.default;
843
+ }
844
+ /**
845
+ * Every preset the configured roots currently supply.
846
+ * @returns the presets, first-root-wins per id.
847
+ */
848
+ async list() {
849
+ return await discoverPresets(this.config.roots);
850
+ }
851
+ /**
852
+ * Resolve one preset by id.
853
+ *
854
+ * A broken preset resolves — deleting one, reading one, and reporting one
855
+ * all need the row — and the mounting paths refuse it AFTER resolution
856
+ * through {@link resolveMountable}.
857
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
858
+ * @returns the resolved preset.
859
+ * @throws when no configured root supplies that id.
860
+ */
861
+ async resolve(id) {
862
+ const wanted = id ?? this.defaultId;
863
+ const presets = await this.list();
864
+ const found = presets.find((preset) => preset.id === wanted);
865
+ if (found === void 0) throw new UnknownPresetError(wanted, presets.map((preset) => preset.id));
866
+ return found;
867
+ }
868
+ /**
869
+ * Resolve one preset that is about to compose an agent, refusing a broken
870
+ * one with its discovery-reported reason. Failing here rather than inside
871
+ * the loader keeps the answer the same for every unloadable shape — ghost
872
+ * directory, unparsable YAML, rowless list — and spends no mount attempt
873
+ * on a composition discovery already read as unusable.
874
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
875
+ * @returns the resolved, mountable preset.
876
+ * @throws when the preset is unknown or discovery reports it broken.
877
+ */
878
+ async resolveMountable(id) {
879
+ const preset = await this.resolve(id);
880
+ if (preset.broken !== void 0) throw new PresetMountError(preset.id, preset.broken);
881
+ return preset;
882
+ }
883
+ /**
884
+ * Standing mounts by preset id, single-flight so two agents racing the
885
+ * first use of one preset share one composition. A settled failure is
886
+ * removed so a later session retries a preset whose file has been fixed; a
887
+ * settled success serves until the composition FILE visibly changes — each
888
+ * generation records its file stamp, and a stale stamp starts the next
889
+ * generation for sessions created afterwards. Sessions already joined keep
890
+ * the generation they run on; a superseded one is never disposed while the
891
+ * process lives (reclaimed only by whole-tree teardown), so editing files
892
+ * is bounded by how often compositions change, not by session count.
893
+ */
894
+ standing = /* @__PURE__ */ new Map();
895
+ /**
896
+ * Parent bindings of the agents this roster composed, keyed by the agent's
897
+ * scope key. The binding is dsh-scope's only re-link capability; holding it
898
+ * here makes this service the sole authority that can move an agent between
899
+ * standing compositions. WeakMap: entries die with their agents.
900
+ */
901
+ bindings = /* @__PURE__ */ new WeakMap();
902
+ /**
903
+ * Compose one agent from a preset: ensure the preset's standing mount, then
904
+ * parent the agent's scope key to it so the mount's registrations and
905
+ * listeners cover this agent.
906
+ *
907
+ * Call from the agent factory's `setup(agentCtx)`; a rejection there rolls
908
+ * the agent creation back, so a broken preset never yields a half-composed
909
+ * session.
910
+ * @param agentCtx - the agent's scope context.
911
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
912
+ * @returns the preset that was composed, for the caller to record.
913
+ * @throws when the preset is unknown or its composition is unusable.
914
+ */
915
+ async mount(agentCtx, id) {
916
+ const agentKey = scopeOf(agentCtx);
917
+ if (agentKey === void 0) throw new Error("agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset");
918
+ const preset = await this.resolveMountable(id);
919
+ const standing = await this.ensureStanding(preset);
920
+ this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
921
+ return preset;
922
+ }
923
+ /**
924
+ * Join one agent to the SAME standing composition another already runs on.
925
+ *
926
+ * This is how a child agent inherits its parent's capabilities. It is a bind,
927
+ * not a mount: the parent's generation is already composed, so the child gets
928
+ * that exact instance — the same plugin objects, the same tool registrations,
929
+ * the same prompt sections. Re-resolving the parent's preset by id instead
930
+ * would re-read the roster, and a composition file edited since the parent
931
+ * started would hand the child a DIFFERENT generation than the one its
932
+ * parent's history was produced under (and a preset deleted since would fail
933
+ * the child outright while its parent keeps running).
934
+ *
935
+ * Synchronous, and with no composition failure mode of its own — it reads no
936
+ * roster, mounts nothing, and touches no file — which is what lets a child
937
+ * creation window use it: the two in-process subagent drivers compose their
938
+ * children inside a synchronous `setup`. It still rejects a caller error, as
939
+ * the `@throws` below record.
940
+ *
941
+ * A parent that joined no preset — a rosterless deployment — yields no join
942
+ * and no error: there, the model-facing rows sit in the host composition and
943
+ * the child already sees them through the global layer.
944
+ * @param agentCtx - the joining agent's scope context.
945
+ * @param parentCtx - the scope context of the agent whose composition to join.
946
+ * @returns the preset id joined, or undefined when the parent joined none.
947
+ * @throws when `agentCtx` carries no scope, or has already joined a preset.
948
+ */
949
+ composeFrom(agentCtx, parentCtx) {
950
+ const agentKey = scopeOf(agentCtx);
951
+ if (agentKey === void 0) throw new Error("agent-presets: refusing to compose an unscoped context; the scope key is what joins an agent to its preset");
952
+ const standing = standingMountFor(parentCtx);
953
+ if (standing === void 0) return void 0;
954
+ this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
955
+ return standing.presetId;
956
+ }
957
+ /**
958
+ * The preset one live agent runs on.
959
+ *
960
+ * Read from the live scope chain rather than from the session, so it answers
961
+ * for an agent whose session has not recorded a preset yet — a child agent
962
+ * whose durable header is being built from its parent's composition.
963
+ * @param agentCtx - the agent's scope context.
964
+ * @returns the preset id, or undefined when the agent joined none.
965
+ */
966
+ composedPreset(agentCtx) {
967
+ return standingMountFor(agentCtx)?.presetId;
968
+ }
969
+ /** Whether this deployment configures a root locally authored presets go to. */
970
+ get authorable() {
971
+ return this.config.roots.some((root) => root.trust === "user");
972
+ }
973
+ /**
974
+ * Read one preset's composition text.
975
+ * @param id - the preset id.
976
+ * @returns the composition exactly as stored.
977
+ * @throws when no configured root supplies that id.
978
+ */
979
+ async read(id) {
980
+ return await readComposition(await this.resolve(id));
981
+ }
982
+ /**
983
+ * Create a locally authored preset by copying an existing one whole.
984
+ *
985
+ * Copy is the only authoring write. Composition text never crosses this
986
+ * seam: the source is named by id and its directory is copied as it stands,
987
+ * so the copy is exactly as loadable as its source and authoring grants no
988
+ * capability the roster did not already carry. The copy is NOT mounted to
989
+ * validate — a source that mounts today yields a copy that mounts today.
990
+ * @param from - the preset the copy starts from; shipped presets are the
991
+ * primary source, so any trust is accepted.
992
+ * @param id - the new preset's id, which becomes its directory name.
993
+ * @param name - display name for the copy; absent falls back to the id.
994
+ * @throws when the source is unknown, the id is unusable or already taken,
995
+ * or the deployment configures no writable root.
996
+ */
997
+ async copy(from, id, name) {
998
+ const source = await this.resolve(from);
999
+ if ((await this.list()).some((preset) => preset.id === id)) throw new PresetExistsError(id);
1000
+ await copyComposition(this.config.roots, source, id, name);
1001
+ this.standing.delete(id);
1002
+ }
1003
+ /**
1004
+ * Delete a locally authored preset.
1005
+ * @param id - the preset id.
1006
+ * @throws when the preset is unknown or ships with the deployment.
1007
+ */
1008
+ async remove(id) {
1009
+ await deleteComposition(this.config.roots, await this.resolve(id));
1010
+ this.standing.delete(id);
1011
+ if (this.settings?.get().default !== id) return;
1012
+ await this.settingsService?.mutate(settingsNamespace(SETTINGS_NAMESPACE), [{
1013
+ op: "unset",
1014
+ path: ["default"]
1015
+ }]);
1016
+ }
1017
+ /**
1018
+ * One agent's instance of a service its preset mounted.
1019
+ *
1020
+ * A preset publishes services behind `isolate` realms, which are invisible
1021
+ * outside the group that declares them — including to the host. This is how a
1022
+ * caller holding the agent reads one anyway: a request that is ABOUT a
1023
+ * session but arrives from outside it, which is every browser RPC.
1024
+ *
1025
+ * Read addressing only. A host row that `inject`s a service cannot use this,
1026
+ * because injection resolves before any session exists and has no agent to
1027
+ * key by; such a service belongs on the host plane instead.
1028
+ * @param agent - the agent whose composition to look inside.
1029
+ * @param name - the service name as the preset's rows resolve it.
1030
+ * @returns the agent's instance, or undefined when its preset mounts none.
1031
+ */
1032
+ serviceFor(agent, name) {
1033
+ return serviceForAgent(this.ctx, agent, name);
1034
+ }
1035
+ /**
1036
+ * Re-link one agent to a different preset's standing composition.
1037
+ *
1038
+ * Only valid while the agent has produced nothing: swapping tools mid
1039
+ * conversation would leave logged tool calls the new composition cannot
1040
+ * make. The CALLER owns that check — this method does not read session
1041
+ * history.
1042
+ *
1043
+ * The swap is a parent re-link, not an unmount: standing mounts are shared
1044
+ * and permanent, so the old composition stays for its other agents and the
1045
+ * new one is ensured BEFORE the link moves. An unknown or unusable preset
1046
+ * therefore throws with the agent exactly as it was — there is no torn-down
1047
+ * state to restore. The re-link runs through the binding this roster kept
1048
+ * from the agent's mount — dsh-scope's only re-link authority. An agent
1049
+ * that never composed one has nothing to re-link: the switch is then the
1050
+ * agent's first bind, exactly a mount.
1051
+ * @param agentCtx - the agent's scope context.
1052
+ * @param id - the preset to compose the agent from instead.
1053
+ * @returns the preset now installed.
1054
+ * @throws when the preset is unknown or its composition is unusable.
1055
+ */
1056
+ async recompose(agentCtx, id) {
1057
+ const agentKey = scopeOf(agentCtx);
1058
+ if (agentKey === void 0) throw new Error("agent-presets: refusing to recompose an unscoped context");
1059
+ const preset = await this.resolveMountable(id);
1060
+ const standing = await this.ensureStanding(preset);
1061
+ const binding = this.bindings.get(agentKey);
1062
+ if (binding === void 0) this.bindings.set(agentKey, bindScopeParent(agentKey, standing.key));
1063
+ else binding.rebind(standing.key);
1064
+ return preset;
1065
+ }
1066
+ /**
1067
+ * The standing scope key of one preset, for a host reader with no agent.
1068
+ *
1069
+ * A cold transcript read resolves tool presenters against the composition
1070
+ * the session recorded, and the standing mount makes that possible without
1071
+ * resuming anything: ensuring the mount composes plugins but starts no
1072
+ * agent, no session, and no turn.
1073
+ * @param id - the preset id, or `undefined` for {@link defaultId}.
1074
+ * @returns the standing scope key readers pass as a registry view scope.
1075
+ * @throws when the preset is unknown or its composition is unusable.
1076
+ */
1077
+ async standingKeyFor(id) {
1078
+ const preset = await this.resolveMountable(id);
1079
+ return (await this.ensureStanding(preset)).key;
1080
+ }
1081
+ /** Resolve (or create, single-flight) the standing mount of one preset. */
1082
+ async ensureStanding(preset) {
1083
+ const pending = this.standing.get(preset.id);
1084
+ if (pending !== void 0) {
1085
+ const mounted = await pending;
1086
+ const current = await compositionStamp(preset.path);
1087
+ if (current === void 0 || sameStamp(mounted.stamp, current)) return mounted;
1088
+ if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id);
1089
+ return this.ensureStanding(preset);
1090
+ }
1091
+ const created = (async () => {
1092
+ const key = { agentPreset: preset.id };
1093
+ const scope = createScope(this.selfCtx, key);
1094
+ try {
1095
+ const stamp = await compositionStamp(preset.path);
1096
+ if (stamp === void 0) throw new PresetMountError(preset.id, `composition file is unreadable: ${preset.path}`);
1097
+ await mountPreset(scope.ctx, preset);
1098
+ return {
1099
+ key,
1100
+ scope,
1101
+ stamp
1102
+ };
1103
+ } catch (error) {
1104
+ this.standing.delete(preset.id);
1105
+ await scope.dispose();
1106
+ throw error;
1107
+ }
1108
+ })();
1109
+ this.standing.set(preset.id, created);
1110
+ return created;
1111
+ }
1112
+ };
1113
+ /** Read one composition file's stamp, or undefined when it cannot be statted. */
1114
+ async function compositionStamp(path) {
1115
+ try {
1116
+ const { mtimeMs, size } = await stat(path);
1117
+ return {
1118
+ mtimeMs,
1119
+ size
1120
+ };
1121
+ } catch {
1122
+ return;
1123
+ }
1124
+ }
1125
+ /** Whether two stamps name the same file state. */
1126
+ function sameStamp(a, b) {
1127
+ return a.mtimeMs === b.mtimeMs && a.size === b.size;
1128
+ }
1129
+ //#endregion
1130
+ export { AgentPresetSettingsSchema, AgentPresets, AgentPresets as default, COMPOSITION_FILE, InvalidPresetIdError, METADATA_FILE, PresetExistsError, PresetMountError, PresetNotWritableError, SETTINGS_NAMESPACE, UnknownPresetError, copyComposition, deleteComposition, discoverPresets, inactiveRows, leakedServices, livePresetMounts, mountPreset, readComposition, readPresetMetadata, renderPresetMetadata, resolveSessionPreset, scanRoot, serviceForAgent, standingMountFor, writableRoot };