@t4r71/dsh-dual-axis 0.1.0

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.
@@ -0,0 +1,1812 @@
1
+ import { resolve, sep } from "node:path";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
4
+ //#region lib/types/axis.js
5
+ /**
6
+ * The read/write ACCESS AXES of the file sandbox: the per-axis vocabulary a
7
+ * session selects and the lossless mapping onto the legacy single `mode`.
8
+ *
9
+ * This is the 0.1.7 port of the algebra upstream deleted. 0.1.6 shipped it as
10
+ * `@deepseek-ai/dsh-sandbox/access` (152 lines) and
11
+ * `@deepseek-ai/dsh-sandbox/scope` (152 lines); 0.1.7 has neither symbol nor
12
+ * file, so the package carries its own copy and keeps the 0.1.6 semantics
13
+ * verbatim: four kinds, `custom` = base + allow - deny with deny winning.
14
+ *
15
+ * One axis answers "which absolute paths may this execution touch". `deny`
16
+ * permits nothing, `workspace` permits the calling session's workspace root
17
+ * plus the platform temp areas, `all` is the whole host filesystem, and
18
+ * `custom` names a BASE kind plus absolute path ADDITIONS and REMOVALS —
19
+ * path lists, not patterns.
20
+ *
21
+ * `mode` REMAINS the write axis's persistence spelling. The session-format
22
+ * whitelist pins its literal values, so this module keeps `mode` derivable
23
+ * from the write scope in both directions instead of replacing it:
24
+ * {@link scopeOfMode} and {@link modeOfScope} are the two halves of that
25
+ * bijection, and `custom` maps onto the mode implied by its own base.
26
+ *
27
+ * @module @t4r71/dsh-dual-axis/axis
28
+ */
29
+ /** Every {@link AxisScopeKind}, for option advertisement and untrusted-value validation. */
30
+ const AXIS_KINDS = [
31
+ "deny",
32
+ "workspace",
33
+ "all",
34
+ "custom"
35
+ ];
36
+ /** Every {@link AxisBase}, for option advertisement and untrusted-value validation. */
37
+ const AXIS_BASES = [
38
+ "deny",
39
+ "workspace",
40
+ "all"
41
+ ];
42
+ /** The default READ axis: every mode permitted reading before the axes existed, so the whole host. */
43
+ const DEFAULT_READ_SCOPE = { kind: "all" };
44
+ /** The default WRITE axis: the session workspace plus the platform temp areas. */
45
+ const DEFAULT_WRITE_SCOPE = { kind: "workspace" };
46
+ //#endregion
47
+ //#region lib/types/scope.js
48
+ /**
49
+ * The shared path-range ALGEBRA behind both access axes: one pure evaluation
50
+ * from an {@link AxisScope} to the canonical allow and deny root sets the
51
+ * fence consumes.
52
+ *
53
+ * This is the 0.1.7 port of 0.1.6's `@deepseek-ai/dsh-sandbox/scope`
54
+ * (152 lines), reduced to what a fence needs and made source-agnostic:
55
+ *
56
+ * - `workspace` derives its roots from 0.1.7's own
57
+ * `writableRoots(policy)` (`@deepseek-ai/dsh-sandbox`, re-exported from
58
+ * `packages/sandbox/sandbox/src/roots.ts:52`), so the fence agrees with
59
+ * the Seatbelt profile and the write fence by construction.
60
+ * - Containment itself is NOT evaluated here. 0.1.6 took the enforcement
61
+ * layer's `contains` predicate as a parameter; this port keeps that shape
62
+ * but narrows it to the SYNCHRONOUS lexical predicate the pure tests use,
63
+ * and the filesystem-identity fallback lives in the fence
64
+ * (`fs-fence.ts`), which is where the canonical target key exists.
65
+ *
66
+ * `resolveScope` returns the sets; the fence applies them with deny-wins
67
+ * precedence through {@link scopeContains}.
68
+ *
69
+ * @module @t4r71/dsh-dual-axis/scope
70
+ */
71
+ /** Thrown when a configured `custom` scope entry cannot name an absolute path. */
72
+ var ScopeConfigError = class extends Error {
73
+ entry;
74
+ value;
75
+ constructor(entry, value) {
76
+ super(`sandbox scope: \`${entry}\` entry ${JSON.stringify(value)} must be a non-empty absolute path`);
77
+ this.entry = entry;
78
+ this.value = value;
79
+ this.name = "ScopeConfigError";
80
+ }
81
+ };
82
+ /** Canonicalize one configured custom entry, failing closed on anything that cannot name an absolute host path. */
83
+ function canonicalEntry(value, entry) {
84
+ if (value.trim().length === 0 || !isAbsoluteSpelling(value)) throw new ScopeConfigError(entry, value);
85
+ return canonicalPath(value);
86
+ }
87
+ /**
88
+ * Whether a configured path is spelled absolutely on this host. Both POSIX
89
+ * (`/x`) and Windows (`C:\\x`, `\\\\server\\share`) spellings are accepted
90
+ * because a policy may be authored for one world and resolved in another; the
91
+ * canonical resolution that follows is what actually binds it to this host.
92
+ * @param path - the configured path spelling.
93
+ * @returns whether the spelling is absolute.
94
+ */
95
+ function isAbsoluteSpelling(path) {
96
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
97
+ }
98
+ /**
99
+ * Evaluate one axis against a workspace root. `deny` permits nothing;
100
+ * `workspace` yields the shared writable roots; `all` is unbounded;
101
+ * `custom` yields its base plus its own additions, with its removals listed
102
+ * separately so the fence can apply them with precedence.
103
+ *
104
+ * A `custom` scope whose base is `all` stays unbounded: "everything except
105
+ * these directories" is still everything-except, so its removals must survive
106
+ * into {@link ResolvedScope.deny} rather than being flattened into an allow
107
+ * list that could not express them.
108
+ * @param scope - the axis value.
109
+ * @param policy - the workspace root `workspace` and `custom` scopes resolve against.
110
+ * @returns the evaluated range.
111
+ * @throws {ScopeConfigError} when a `custom` entry is not a non-empty absolute path.
112
+ */
113
+ function resolveScope(scope, policy) {
114
+ if (scope.kind !== "custom") return resolveBase(scope.kind, policy);
115
+ const base = resolveBase(scope.base, policy);
116
+ const allow = [...base.allow, ...scope.allow.map((entry) => canonicalEntry(entry, "allow"))];
117
+ const deny = scope.deny.map((entry) => canonicalEntry(entry, "deny"));
118
+ return {
119
+ unbounded: base.unbounded,
120
+ allow: dedupe(allow),
121
+ deny: dedupe(deny)
122
+ };
123
+ }
124
+ /** Evaluate a base (or closed) kind — the shared half of {@link resolveScope}. */
125
+ function resolveBase(base, policy) {
126
+ switch (base) {
127
+ case "deny": return {
128
+ unbounded: false,
129
+ allow: [],
130
+ deny: []
131
+ };
132
+ case "workspace": return {
133
+ unbounded: false,
134
+ allow: dedupe(writableRoots({
135
+ mode: "workspace-write",
136
+ workspaceRoot: policy.workspaceRoot
137
+ })),
138
+ deny: []
139
+ };
140
+ case "all": return {
141
+ unbounded: true,
142
+ allow: [],
143
+ deny: []
144
+ };
145
+ }
146
+ }
147
+ /** Stable-order dedupe: keeps the first spelling of each value. */
148
+ function dedupe(values) {
149
+ return [...new Set(values)];
150
+ }
151
+ /**
152
+ * Whether `target` is the root itself or lies beneath it, by canonical
153
+ * spelling. Case-insensitive on Windows, matching that filesystem's
154
+ * convention.
155
+ * @param target - canonical target path.
156
+ * @param root - canonical root path.
157
+ * @param caseSensitive - whether lexical comparison preserves case; defaults to the host convention.
158
+ * @returns whether the target is the root or a descendant of it.
159
+ */
160
+ function isLexicallyUnder(target, root, caseSensitive = process.platform !== "win32") {
161
+ const comparableTarget = caseSensitive ? target : target.toLowerCase();
162
+ const comparableRoot = caseSensitive ? root : root.toLowerCase();
163
+ if (comparableTarget === comparableRoot) return true;
164
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
165
+ return comparableTarget.startsWith(prefix);
166
+ }
167
+ /**
168
+ * Whether `targetKey` is permitted by an evaluated scope. Removal wins over
169
+ * addition: a deny root containing the target refuses it even when an allow
170
+ * root — or an unbounded axis — would otherwise permit it.
171
+ * @param scope - the evaluated scope.
172
+ * @param targetKey - the target's canonical identity key (the resolved path).
173
+ * @param contains - the containment predicate; defaults to {@link isLexicallyUnder}.
174
+ * @returns whether the scope permits the target.
175
+ */
176
+ function scopeContains(scope, targetKey, contains = isLexicallyUnder) {
177
+ for (const root of scope.deny) if (contains(targetKey, root)) return false;
178
+ if (scope.unbounded) return true;
179
+ for (const root of scope.allow) if (contains(targetKey, root)) return true;
180
+ return false;
181
+ }
182
+ //#endregion
183
+ //#region lib/types/config.js
184
+ /**
185
+ * The dual-axis settings surface: the 0.1.7 spelling of the section 0.1.6
186
+ * registered through `ctx.settings.installSection`.
187
+ *
188
+ * 0.1.7 removed both `installSection` and `settings.register(ns, schema)`:
189
+ * `SettingsForms.describe()` projects exactly the Config schemas of LOADED
190
+ * Loader entries (`packages/settings/settings/src/index.ts:302-340`) and
191
+ * `write()` refuses a namespace with no matching entry
192
+ * (`:382-384`, `No configurable plugin entry`). The namespace IS the entry
193
+ * id, so this package's own row id in `cordis.patch.yml` — `dual-axis` — is
194
+ * the settings namespace. 0.1.6's separate `sandbox-axis` namespace name is
195
+ * therefore retired.
196
+ *
197
+ * 0.1.7 also added the volatile gate: a field that is not beneath a
198
+ * `.volatile()` node is neither projected into the form
199
+ * (`packages/settings/settings/src/schema.ts:37-47`) nor writable
200
+ * (`:74-78`), and an entry with no volatile field at all is refused outright
201
+ * (`index.ts:385-386`). Both axes are marked volatile here.
202
+ *
203
+ * The schema is deliberately `z.any()` per axis, exactly as in 0.1.6:
204
+ * schemastery's union types strip the `custom` branch's `base/allow/deny`
205
+ * payload, which would silently degrade a configured custom axis into an axis
206
+ * carrying no paths. Shape validation is {@link normalizeScope}'s job.
207
+ *
208
+ * @module @t4r71/dsh-dual-axis/config
209
+ */
210
+ /**
211
+ * This package's row id in `cordis.patch.yml`. It doubles as the 0.1.7
212
+ * settings namespace (the Loader entry id) and, prefixed with the package
213
+ * name, as the `plugins.row.config` slot entry key the client half uses.
214
+ */
215
+ const DUAL_AXIS_ROW_ID = "dual-axis";
216
+ z.object({
217
+ read: z.any().volatile(),
218
+ write: z.any().volatile(),
219
+ groups: z.array(z.any()).default([]).volatile(),
220
+ defaultGroups: z.array(z.string()).default([]).volatile()
221
+ });
222
+ /**
223
+ * Collect one `custom` axis's path list: every entry must be a non-empty
224
+ * absolute path.
225
+ * @param label - the axis name used in the error message.
226
+ * @param entry - the list name used in the error message (`allow` or `deny`).
227
+ * @param value - the untrusted list value.
228
+ * @returns the validated path list.
229
+ * @throws When the value is not a string array, or holds a non-absolute path.
230
+ */
231
+ function pathList(label, entry, value) {
232
+ if (value === void 0) return [];
233
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`dual-axis: ${label} ${entry} must be an array of absolute path strings`);
234
+ for (const item of value) if (item.length === 0 || !isAbsoluteSpelling(item)) throw new Error(`dual-axis: ${label} ${entry} entry ${JSON.stringify(item)} must be an absolute path`);
235
+ return [...value];
236
+ }
237
+ /**
238
+ * Coerce one axis value from a config document (which a human may have edited)
239
+ * into a legal {@link AxisScope}, throwing rather than guessing.
240
+ *
241
+ * An unreadable axis must not become some other, wider or narrower grant:
242
+ * throwing fails the row's mount or the settings write on the spot instead of
243
+ * proceeding under an invented boundary. This is 0.1.6's `normalizeScope`,
244
+ * unchanged.
245
+ * @param label - the axis name used in error messages (`read` or `write`).
246
+ * @param value - the untrusted axis value.
247
+ * @returns the validated axis value.
248
+ * @throws When the value is not one of the four kinds, or a custom entry is not absolute.
249
+ */
250
+ function normalizeScope(label, value) {
251
+ if (typeof value !== "object" || value === null) throw new Error(`dual-axis: ${label} must be an access-axis object, received ${JSON.stringify(value)}`);
252
+ const candidate = value;
253
+ if (typeof candidate.kind !== "string" || !AXIS_KINDS.includes(candidate.kind)) throw new Error(`dual-axis: ${label} carries unknown kind ${JSON.stringify(candidate.kind)} (expected: ${AXIS_KINDS.join(", ")})`);
254
+ if (candidate.kind !== "custom") return { kind: candidate.kind };
255
+ if (typeof candidate.base !== "string" || !AXIS_BASES.includes(candidate.base)) throw new Error(`dual-axis: ${label} custom base must be ${AXIS_BASES.join(", ")}, received ${JSON.stringify(candidate.base)}`);
256
+ return {
257
+ kind: "custom",
258
+ base: candidate.base,
259
+ groups: groupIds(label, candidate.groups),
260
+ allow: pathList(label, "allow", candidate.allow),
261
+ deny: pathList(label, "deny", candidate.deny)
262
+ };
263
+ }
264
+ /**
265
+ * Collect one `custom` axis's rule-group references. Only the speaker matters
266
+ * here — an id is a name the settings page resolves, so this checks that the
267
+ * list is a list of non-empty names and nothing more; whether the name exists is
268
+ * decided at the moment of use ({@link resolveEffectiveAxes}), where a missing
269
+ * one can be refused instead of quietly dropped.
270
+ * @param label - the axis name used in the error message.
271
+ * @param value - the untrusted `groups` value.
272
+ * @returns the validated id list.
273
+ * @throws When the value is not an array of non-empty strings.
274
+ */
275
+ function groupIds(label, value) {
276
+ if (value === void 0) return [];
277
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`dual-axis: ${label} groups must be an array of non-empty rule-group ids`);
278
+ return [...new Set(value)];
279
+ }
280
+ /**
281
+ * Read both axes out of one settings/config value.
282
+ *
283
+ * The parameter is UNTRUSTED on purpose: the same function serves the
284
+ * composition layer's parsed Config, the settings service's projected
285
+ * descriptor (an `unknown` on the wire between two packages), and a
286
+ * hand-edited document, and none of those three is guaranteed to carry the
287
+ * declared shape. Both axes therefore pass through {@link normalizeScope},
288
+ * which is where an illegal value becomes a throw instead of an invented
289
+ * boundary; a missing side falls back to the built-in default axis.
290
+ * @param section - the section's current resolved value, whatever carries it.
291
+ * @returns both axes.
292
+ * @throws When either axis's shape is illegal.
293
+ */
294
+ function axesOf(section) {
295
+ const declared = section === null || typeof section !== "object" ? {} : section;
296
+ return {
297
+ read: normalizeScope("read", unwrapVolatile(declared.read) ?? DEFAULT_READ_SCOPE),
298
+ write: normalizeScope("write", unwrapVolatile(declared.write) ?? DEFAULT_WRITE_SCOPE)
299
+ };
300
+ }
301
+ /**
302
+ * Unwrap one config field's value.
303
+ *
304
+ * A `.volatile()` field is handed to the plugin as a cordis `Volatile<T>`
305
+ * **accessor**, not as `T`: the schema's `get()` re-reads the stored value on
306
+ * every call so an edit applies without remounting the entry. Reading the
307
+ * property directly therefore yields the accessor object, whose `kind` is
308
+ * `undefined` — which {@link normalizeScope} correctly refuses, failing the
309
+ * whole row at mount. The upstream volatile Config
310
+ * (`packages/core/agent-default-model/src/index.ts:68-71`) unwraps the same way.
311
+ *
312
+ * A hand-written config document may also carry the plain value, so both
313
+ * spellings are accepted.
314
+ * @param value - the configured field, as the accessor, as a plain value, or as
315
+ * whatever an untrusted document put under the key.
316
+ * @returns the underlying value, or `undefined` when this field is unset.
317
+ */
318
+ function unwrapVolatile(value) {
319
+ if (value === void 0) return void 0;
320
+ if (typeof value.get === "function") return value.get();
321
+ return value;
322
+ }
323
+ /**
324
+ * This row's current value on the settings page, or `undefined` when no
325
+ * settings service is mounted.
326
+ *
327
+ * This is the ONE read path to that row. It goes through `describe()` rather
328
+ * than the config the plugin instance captured at mount because the loader does
329
+ * not commit a later save back to that instance
330
+ * (`vendor/loader/src/config/entry.ts:162-195`); `describe()` re-projects
331
+ * `entry.fiber.config` and unwraps the volatile accessors on every call
332
+ * (`packages/settings/settings/src/index.ts:319-323` +
333
+ * `packages/settings/settings/src/schema.ts:11`), which is the same path the
334
+ * settings page renders from. A save therefore applies without a restart.
335
+ * @param ctx - host context; the service is looked up, never injected.
336
+ * @returns the row's current value, or `undefined` when unavailable.
337
+ */
338
+ function declaredSection(ctx) {
339
+ return ctx.get("settings")?.describe().find((row) => row.ns === DUAL_AXIS_ROW_ID)?.value;
340
+ }
341
+ /**
342
+ * The group library a decision path may read, and the ONLY part of the settings
343
+ * row one may read for a decision.
344
+ *
345
+ * The row's `read`, `write` and `defaultGroups` fields are seeds for
346
+ * NEWLY CREATED sessions and carry no authority over an existing one; a
347
+ * decision path that read them would let the settings page silently re-scope a
348
+ * running session, which is the drift this package exists to prevent.
349
+ * @param section - the row's current value, as {@link declaredSection} returns it.
350
+ * @returns the untrusted `groups` value, for {@link resolveEffectiveAxes}.
351
+ */
352
+ function groupLibraryValue(section) {
353
+ return section === null || typeof section !== "object" ? void 0 : unwrapVolatile(section.groups);
354
+ }
355
+ /**
356
+ * The group ids a newly created session starts out referencing.
357
+ * @param section - the row's current value, as {@link declaredSection} returns it.
358
+ * @returns the ids, deduplicated; empty when the row declares none.
359
+ * @throws When a declared id is not a non-empty string.
360
+ */
361
+ function defaultGroupIds(section) {
362
+ return groupIds("defaultGroups", section === null || typeof section !== "object" ? void 0 : unwrapVolatile(section.defaultGroups));
363
+ }
364
+ //#endregion
365
+ //#region lib/types/content.js
366
+ /**
367
+ * Whether a session has been USED yet — the one predicate that decides when its
368
+ * axis pair freezes.
369
+ *
370
+ * A session the workspace picker reopens in the same workspace is the same
371
+ * session id with the same stored record, so the axes it was seeded with at
372
+ * creation are what its two dropdowns show for the rest of its life. That is
373
+ * correct for a session someone has actually worked in; for one nobody has ever
374
+ * exchanged a turn with it is wrong, because the settings row is a live default
375
+ * and the person changing it expects the next conversation to follow.
376
+ *
377
+ * So the record is written when the session is used, not when it is created:
378
+ *
379
+ * - nothing appended beyond the loop's runtime-context snapshot → no record; the
380
+ * seed recomputed from the CURRENT settings row answers every read, so the
381
+ * dropdown follows the settings page in real time;
382
+ * - one real user turn, or an assistant reply → the record is written and the
383
+ * pair freezes;
384
+ * - `/axis` → {@link SessionAxesStore.set} writes unconditionally, so a manual
385
+ * pick always freezes, on an empty session too.
386
+ *
387
+ * ## What counts as "content"
388
+ *
389
+ * Everything except a message whose source is the loop's own runtime-context
390
+ * snapshot. That snapshot is appended by the agent loop on EVERY turn
391
+ * (`packages/core/agent-loop/src/runtime-context.ts`), including the very first
392
+ * one, and it is present before any human input exists — the four events a
393
+ * freshly created session carries are the header, the preset, the mode, and the
394
+ * approval policy. Counting it would make every session look used the moment
395
+ * anything rendered its prompt, which is exactly the state this predicate has to
396
+ * tell apart.
397
+ *
398
+ * The snapshot is identified by its `source.kind`, the discriminant the loop
399
+ * writes and reads back (`isOwned` in that module requires `kind === 'plugin'`;
400
+ * the system-prompt renderer stamps `kind: 'runtime-context'`). Matching on the
401
+ * kind is cheaper and more robust than matching the rendered text, which is
402
+ * localized prose that changes with every contribution.
403
+ *
404
+ * Every other message counts, the skill catalogue and the compaction markers
405
+ * included: each of them means the session has entered a turn, and a session
406
+ * that has entered a turn is one somebody is using.
407
+ *
408
+ * @module @t4r71/dsh-dual-axis/content
409
+ */
410
+ /**
411
+ * The `source.kind` of the loop's per-turn runtime-context snapshot.
412
+ *
413
+ * @see `packages/core/agent-loop/src/runtime-context.ts` — `RuntimeContextProjection.project`
414
+ * builds the message, and the renderer in
415
+ * `packages/core/system-prompt/src/index.ts` stamps this kind on it.
416
+ */
417
+ const RUNTIME_CONTEXT_KIND = "runtime-context";
418
+ /**
419
+ * Whether a session has content beyond the loop's own runtime-context snapshots.
420
+ *
421
+ * Cheap by construction: it walks the session's existing event array, which is
422
+ * already materialized and cached, without copying, parsing, or allocating per
423
+ * event. It is called on every `ensure` for a session that has no record, and
424
+ * that set is exactly the sessions nobody has used — so the scan is short.
425
+ * @param session - the session to judge.
426
+ * @returns whether this session has been used.
427
+ */
428
+ function hasContent(session) {
429
+ for (const event of session.snapshotEvents()) {
430
+ if (event.type === "user/message") {
431
+ if (event.data.source?.kind === RUNTIME_CONTEXT_KIND) continue;
432
+ return true;
433
+ }
434
+ if (event.type === "assistant/message" || event.type === "system/message") return true;
435
+ }
436
+ return false;
437
+ }
438
+ //#endregion
439
+ //#region lib/types/groups.js
440
+ /**
441
+ * RULE GROUPS and THE ONE resolution from a session's axis preset to the ranges
442
+ * actually in force.
443
+ *
444
+ * A rule group is a named fragment of path rules owned by the settings page
445
+ * (`dual-axis`'s `groups` Config field). A session references groups BY ID and
446
+ * never stores their content: ten sessions selecting one group must not become
447
+ * ten copies of it, because copies drift. Resolving an id against the CURRENT
448
+ * library is therefore the only place a group's rules enter a decision, and
449
+ * editing a group takes effect on every session referencing it without a
450
+ * restart.
451
+ *
452
+ * This module is that place, for both axes and for both invariants that ride on
453
+ * them:
454
+ *
455
+ * - **deny wins over allow**, with session-level entries and every selected
456
+ * group merged into ONE allow list and ONE deny list first
457
+ * ({@link resolveEffectiveAxes}), so a group's removal cannot be overridden by
458
+ * a session-level addition.
459
+ * - **write never exceeds read**: the effective write range is the write axis's
460
+ * range INTERSECTED with the read axis's range. A write outside the read range
461
+ * would let an agent overwrite a file it cannot read back, so the intersection
462
+ * is computed here — in the resolution — and never in a user interface.
463
+ *
464
+ * Both the fence and the model-facing prompt call {@link resolveEffectiveAxes},
465
+ * so the range the model is told about and the range it is held to are the same
466
+ * computation over the same inputs: the session's own preset (read and write
467
+ * axes, their own additions and removals, and the group ids they selected) plus
468
+ * the current definitions of those ids. The settings page's `read` / `write`
469
+ * defaults and its `defaultGroups` seed are NOT among those inputs: they are
470
+ * read once, when a session is created.
471
+ *
472
+ * A reference to an id the library does not define is refused, never treated as
473
+ * an empty group: "I excluded it and nothing happened" is the worst failure this
474
+ * surface can have.
475
+ *
476
+ * @module @t4r71/dsh-dual-axis/groups
477
+ */
478
+ /** The narrowing that removed nothing. Exported: it is the stored record's absent value. */
479
+ const NO_NARROWING = {
480
+ narrowed: false,
481
+ droppedRoots: [],
482
+ lostUnbounded: false
483
+ };
484
+ /**
485
+ * Read one stored narrowing report, or throw naming the member that is
486
+ * unreadable.
487
+ *
488
+ * An absent member is the legal "nothing was ever published for this record"
489
+ * state and yields {@link NO_NARROWING}: that is exactly what the fence enforced
490
+ * before this member existed, so the surface shows no narrowing rather than
491
+ * inventing one. Anything PRESENT must be well formed — a report whose root list
492
+ * cannot be read would otherwise be displayed as "nothing was removed".
493
+ * @param value - the untrusted `narrowing` member of a stored record.
494
+ * @returns the validated report.
495
+ * @throws When the member is present and not a well-formed report.
496
+ */
497
+ function parseNarrowing(value) {
498
+ if (value === void 0) return NO_NARROWING;
499
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("dual-axis: a stored narrowing report must be an object");
500
+ const candidate = value;
501
+ if (typeof candidate.narrowed !== "boolean" || typeof candidate.lostUnbounded !== "boolean") throw new Error("dual-axis: a stored narrowing report needs boolean narrowed and lostUnbounded");
502
+ return {
503
+ narrowed: candidate.narrowed,
504
+ droppedRoots: pathList("stored narrowing", "droppedRoots", candidate.droppedRoots),
505
+ lostUnbounded: candidate.lostUnbounded
506
+ };
507
+ }
508
+ /**
509
+ * Parse one group's rules for one axis.
510
+ * @param label - the group and axis, as the error message should name them.
511
+ * @param value - the untrusted `read` or `write` member.
512
+ * @returns the validated rules, or `undefined` when the member is absent.
513
+ * @throws When the member is neither absent nor an object of absolute path lists.
514
+ */
515
+ function axisRules(label, value) {
516
+ if (value === void 0) return void 0;
517
+ if (value === null || typeof value !== "object") throw new Error(`dual-axis: ${label} must be an object carrying allow and/or deny`);
518
+ const candidate = value;
519
+ return {
520
+ allow: pathList(label, "allow", candidate.allow),
521
+ deny: pathList(label, "deny", candidate.deny)
522
+ };
523
+ }
524
+ /**
525
+ * Read one group entry out of the library.
526
+ * @param index - the entry's position, used in error messages.
527
+ * @param value - the untrusted entry.
528
+ * @returns the validated group.
529
+ * @throws When the entry carries no usable id.
530
+ */
531
+ function groupAt(index, value) {
532
+ if (value === null || typeof value !== "object") throw new Error(`dual-axis: rule group #${String(index)} must be an object`);
533
+ const candidate = value;
534
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) throw new Error(`dual-axis: rule group #${String(index)} needs a non-empty string id`);
535
+ const label = "rule group " + JSON.stringify(candidate.id);
536
+ const read = candidate.read === void 0 ? void 0 : axisRules(label + " read", candidate.read);
537
+ const write = candidate.write === void 0 ? void 0 : axisRules(label + " write", candidate.write);
538
+ return {
539
+ id: candidate.id,
540
+ name: typeof candidate.name === "string" && candidate.name.length > 0 ? candidate.name : candidate.id,
541
+ ...read === void 0 ? {} : { read },
542
+ ...write === void 0 ? {} : { write }
543
+ };
544
+ }
545
+ /**
546
+ * Parse the settings page's group library. An unset or null library is the
547
+ * legal "no groups configured" state; anything else must be a well-formed
548
+ * library, because a half-read library would silently drop a removal.
549
+ * @param value - the untrusted `groups` Config value.
550
+ * @returns the parsed library by id, or the sentence explaining why it is unusable.
551
+ */
552
+ function parseLibrary(value) {
553
+ if (value === void 0 || value === null) return {
554
+ ok: true,
555
+ byId: /* @__PURE__ */ new Map()
556
+ };
557
+ if (!Array.isArray(value)) return {
558
+ ok: false,
559
+ problem: "the rule-group library must be an array of groups"
560
+ };
561
+ const byId = /* @__PURE__ */ new Map();
562
+ try {
563
+ for (let index = 0; index < value.length; index += 1) {
564
+ const group = groupAt(index, value[index]);
565
+ if (byId.has(group.id)) return {
566
+ ok: false,
567
+ problem: "the rule-group library defines " + JSON.stringify(group.id) + " more than once"
568
+ };
569
+ byId.set(group.id, group);
570
+ }
571
+ } catch (error) {
572
+ return {
573
+ ok: false,
574
+ problem: error instanceof Error ? error.message : String(error)
575
+ };
576
+ }
577
+ return {
578
+ ok: true,
579
+ byId
580
+ };
581
+ }
582
+ /**
583
+ * The group ids a preset references, in first-seen order and without repeats.
584
+ * Callers use this to skip reading the settings page at all for the sessions
585
+ * that reference no groups.
586
+ * @param preset - the session's own axis preset.
587
+ * @returns the referenced ids.
588
+ */
589
+ function referencedGroupIds(preset) {
590
+ const ids = [];
591
+ for (const axis of [preset.read, preset.write]) {
592
+ if (axis.kind !== "custom") continue;
593
+ for (const id of axis.groups ?? []) if (!ids.includes(id)) ids.push(id);
594
+ }
595
+ return ids;
596
+ }
597
+ /**
598
+ * Expand one axis: its own additions and removals first, then those of every
599
+ * group it references, merged into one allow list and one deny list. The result
600
+ * carries no group ids — expansion is the resolution, and a resolved value that
601
+ * still referenced groups could be expanded a second time.
602
+ * @param axis - the axis preset.
603
+ * @param side - which of a group's two rule sets applies.
604
+ * @param byId - the parsed library.
605
+ * @returns the expanded axis.
606
+ */
607
+ function expandAxis(axis, side, byId) {
608
+ if (axis.kind !== "custom") return axis;
609
+ const ids = axis.groups ?? [];
610
+ if (ids.length === 0) return axis;
611
+ const allow = [...axis.allow];
612
+ const deny = [...axis.deny];
613
+ for (const id of ids) {
614
+ const rules = byId.get(id)?.[side];
615
+ if (rules === void 0) continue;
616
+ allow.push(...rules.allow ?? []);
617
+ deny.push(...rules.deny ?? []);
618
+ }
619
+ return {
620
+ kind: "custom",
621
+ base: axis.base,
622
+ allow: [...new Set(allow)],
623
+ deny: [...new Set(deny)]
624
+ };
625
+ }
626
+ /**
627
+ * Attach the group ids a NEW session starts out referencing to the axes that are
628
+ * `custom`, leaving every other axis exactly as declared.
629
+ *
630
+ * The rule is judged per axis, and only an axis whose OWN kind is `custom` can
631
+ * carry a reference: checking the settings row instead (a tick that is not
632
+ * tied to an axis) promoted `read: all` to `read: { kind: 'custom', base: 'all' }`
633
+ * even though no axis was ever set to custom. An axis of one of the three closed
634
+ * kinds has nowhere to put an id and is seeded verbatim — promoting it would
635
+ * report a custom scope the settings page never showed.
636
+ *
637
+ * A group may carry rules for either axis or both, and the session selected it
638
+ * once, so the same list goes on both `custom` axes; the group's `read` rules
639
+ * then apply where they exist and its `write` rules where they exist. Two axes
640
+ * are therefore independent: one may take the references while the other keeps
641
+ * its closed kind.
642
+ * @param axes - the pair a new session is seeded with.
643
+ * @param ids - the settings page's `defaultGroups`.
644
+ * @returns the pair carrying those references.
645
+ */
646
+ function seedGroupReferences(axes, ids) {
647
+ if (ids.length === 0) return axes;
648
+ const attach = (axis) => axis.kind === "custom" ? {
649
+ ...axis,
650
+ groups: [.../* @__PURE__ */ new Set([...axis.groups ?? [], ...ids])]
651
+ } : axis;
652
+ return {
653
+ read: attach(axes.read),
654
+ write: attach(axes.write)
655
+ };
656
+ }
657
+ /**
658
+ * The intersection of two evaluated ranges, as an evaluated range. Exact for
659
+ * canonical directory roots: two roots are either nested — the deeper one is
660
+ * their intersection — or disjoint, and a union of directories intersected with
661
+ * a union of directories is the union of those pairwise intersections.
662
+ * @param left - one evaluated range.
663
+ * @param right - the other evaluated range.
664
+ * @returns the range permitting exactly what both permit.
665
+ */
666
+ function intersectResolved(left, right) {
667
+ const deny = [.../* @__PURE__ */ new Set([...left.deny, ...right.deny])];
668
+ if (left.unbounded && right.unbounded) return {
669
+ unbounded: true,
670
+ allow: [],
671
+ deny
672
+ };
673
+ if (left.unbounded) return {
674
+ unbounded: false,
675
+ allow: [...right.allow],
676
+ deny
677
+ };
678
+ if (right.unbounded) return {
679
+ unbounded: false,
680
+ allow: [...left.allow],
681
+ deny
682
+ };
683
+ const allow = [];
684
+ for (const one of left.allow) for (const other of right.allow) if (isLexicallyUnder(one, other) && !allow.includes(one)) allow.push(one);
685
+ else if (isLexicallyUnder(other, one) && !allow.includes(other)) allow.push(other);
686
+ return {
687
+ unbounded: false,
688
+ allow,
689
+ deny
690
+ };
691
+ }
692
+ /**
693
+ * Spell an evaluated range as an axis value that evaluates back to it. The
694
+ * `deny` base plus the roots themselves is the exact spelling: a `workspace`
695
+ * base would re-add the platform temp areas this range may never have had.
696
+ * @param resolved - the evaluated range.
697
+ * @returns the equivalent axis value.
698
+ */
699
+ function scopeOfResolved(resolved) {
700
+ return resolved.unbounded ? {
701
+ kind: "custom",
702
+ base: "all",
703
+ allow: [],
704
+ deny: [...resolved.deny]
705
+ } : {
706
+ kind: "custom",
707
+ base: "deny",
708
+ allow: [...resolved.allow],
709
+ deny: [...resolved.deny]
710
+ };
711
+ }
712
+ /**
713
+ * The removals in a range that remove something, so two ranges that differ only
714
+ * in irrelevant removals compare equal.
715
+ * @param scope - the evaluated range.
716
+ * @returns the deny roots that would otherwise be permitted.
717
+ */
718
+ function relevantDenies(scope) {
719
+ const permitAll = {
720
+ unbounded: scope.unbounded,
721
+ allow: scope.allow,
722
+ deny: []
723
+ };
724
+ return scope.deny.filter((root) => scopeContains(permitAll, root, isLexicallyUnder));
725
+ }
726
+ /** Whether two evaluated ranges permit exactly the same paths. */
727
+ function sameRange(left, right) {
728
+ const sameSet = (one, other) => one.length === other.length && one.every((value) => other.includes(value));
729
+ if (left.unbounded !== right.unbounded) return false;
730
+ if (left.unbounded) return sameSet(relevantDenies(left), relevantDenies(right));
731
+ return sameSet(left.allow, right.allow) && sameSet(relevantDenies(left), relevantDenies(right));
732
+ }
733
+ /**
734
+ * What the intersection removed: every root the write axis permitted that the
735
+ * effective range does not, counting both roots dropped from the allow list and
736
+ * roots the read axis newly denies.
737
+ * @param before - the write axis's own range.
738
+ * @param after - the effective write range.
739
+ * @returns the narrowing report.
740
+ */
741
+ function narrowingOf(before, after) {
742
+ const lostUnbounded = before.unbounded && !after.unbounded;
743
+ const droppedRoots = [...before.allow.filter((root) => !scopeContains(after, root, isLexicallyUnder)), ...after.deny.filter((root) => !before.deny.includes(root) && scopeContains(before, root, isLexicallyUnder))];
744
+ const unique = [...new Set(droppedRoots)];
745
+ return {
746
+ narrowed: lostUnbounded || unique.length > 0,
747
+ droppedRoots: unique,
748
+ lostUnbounded
749
+ };
750
+ }
751
+ /**
752
+ * Expand one axis against the library, reporting the ids it references and the
753
+ * library does not define.
754
+ * @param axis - the axis preset.
755
+ * @param side - which of a group's two rule sets applies.
756
+ * @param byId - the parsed library.
757
+ * @param missing - collects the undefined ids, in reference order.
758
+ * @returns the expanded axis.
759
+ */
760
+ function expandChecked(axis, side, byId, missing) {
761
+ if (axis.kind === "custom") {
762
+ for (const id of axis.groups ?? []) if (!byId.has(id) && !missing.includes(id)) missing.push(id);
763
+ }
764
+ return expandAxis(axis, side, byId);
765
+ }
766
+ /** The sentence naming the ids a preset references and the library does not define. */
767
+ function missingGroupsProblem(ids) {
768
+ return "this session references rule group(s) " + ids.map((id) => JSON.stringify(id)).join(", ") + " that the rule-group library does not define";
769
+ }
770
+ /**
771
+ * THE resolution: one session's axis preset plus the current group library into
772
+ * the read and write ranges actually in force.
773
+ *
774
+ * The write member of the result is the intersection of the write axis's range
775
+ * with the read axis's. The write axis keeps its own spelling whenever the
776
+ * intersection changed nothing, so a session that references no groups and needs
777
+ * no narrowing is described and enforced exactly as before groups existed.
778
+ *
779
+ * An unresolvable reference or an unusable library returns `ok: false` and no
780
+ * axes: the caller refuses, because resolving a missing id to an empty group
781
+ * would silently drop rules the user configured.
782
+ * @param preset - the session's own axis preset, group ids included.
783
+ * @param library - the settings page's current `groups` value, untrusted.
784
+ * @param policy - the workspace root a `workspace` base resolves against.
785
+ * @returns the effective pair, or the sentence explaining why there is none.
786
+ */
787
+ function resolveEffectiveAxes(preset, library, policy) {
788
+ const parsed = parseLibrary(library);
789
+ if (!parsed.ok) return {
790
+ ok: false,
791
+ problem: parsed.problem
792
+ };
793
+ const missing = [];
794
+ const read = expandChecked(preset.read, "read", parsed.byId, missing);
795
+ const write = expandChecked(preset.write, "write", parsed.byId, missing);
796
+ if (missing.length > 0) return {
797
+ ok: false,
798
+ problem: missingGroupsProblem(missing)
799
+ };
800
+ let readRange;
801
+ let writeRange;
802
+ try {
803
+ readRange = resolveScope(read, policy);
804
+ writeRange = resolveScope(write, policy);
805
+ } catch (error) {
806
+ return {
807
+ ok: false,
808
+ problem: error instanceof Error ? error.message : String(error)
809
+ };
810
+ }
811
+ const intersected = intersectResolved(writeRange, readRange);
812
+ if (sameRange(writeRange, intersected)) return {
813
+ ok: true,
814
+ axes: {
815
+ read,
816
+ write,
817
+ narrowing: NO_NARROWING
818
+ }
819
+ };
820
+ return {
821
+ ok: true,
822
+ axes: {
823
+ read,
824
+ write: scopeOfResolved(intersected),
825
+ narrowing: narrowingOf(writeRange, intersected)
826
+ }
827
+ };
828
+ }
829
+ //#endregion
830
+ //#region lib/types/session-store.js
831
+ /**
832
+ * THE per-session axis store, outside the session log.
833
+ *
834
+ * The axes cannot live in a session's own log: this package's own event type is
835
+ * not in 0.1.7's `KNOWN_SESSION_EVENT_TYPES`, and `Session.append` takes no
836
+ * envelope option, so the event cannot be marked `ignorable` and a log carrying
837
+ * it is refused WHOLE by `validateStoredEvents`
838
+ * (`packages/session/session-persistence/src/storage-contract.ts:75-77`).
839
+ * A session this package had written to could therefore not be opened at all
840
+ * after a restart.
841
+ *
842
+ * The axes live instead in this package's OWN settings namespace,
843
+ * `dual-axis-sessions`, partitioned by session id. That namespace is a
844
+ * separate Loader entry from `dual-axis` because the row's namespace carries the
845
+ * settings page's read/write/defaultGroups form: an undeclared field inside that
846
+ * row would be projected into the row's form. This namespace declares exactly one
847
+ * field and the settings page never renders it.
848
+ *
849
+ * `sandbox/mode` REMAINS the write axis's legal landing place in the log — see
850
+ * `./session-axes.ts`, which mirrors the write base onto it. The log therefore
851
+ * still records the containment the operating-system layer enforces; what it no
852
+ * longer records is the path-level axis pair, which is this module's.
853
+ *
854
+ * A session with NO record is neither an error state nor a reason to hide a
855
+ * control: every live decision path reads through {@link SessionAxesStore.ensure},
856
+ * which answers {@link seedAxesFor} — the deployment seed a newly created session
857
+ * is pinned with — and hands that SAME value to the ONE repair write it
858
+ * schedules. Such a session therefore runs under the deployment's seed from its
859
+ * first read, not under {@link DEFAULT_AXES} until the record lands, and the pair
860
+ * a picker shows before the record exists is the pair the fence and the prompt
861
+ * are already enforcing (the client half recomputes that seed from the same row —
862
+ * see `sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`).
863
+ * {@link DEFAULT_AXES} is now only the answer {@link SessionAxesStore.getOr} gives
864
+ * a caller that holds no session to build a seed from.
865
+ *
866
+ * A session with no record that has ALSO never been used is a different case, and
867
+ * it is the one the workspace picker creates every time: reopening the same
868
+ * workspace reopens the same session id, so freezing it at creation would pin the
869
+ * axes of a conversation nobody has had to whatever the settings row said that
870
+ * day. Its record is therefore written when it is USED ({@link hasContent}), not
871
+ * when it is created: until then every read recomputes the seed from the row as
872
+ * it stands NOW, and the dropdown follows the settings page in real time.
873
+ *
874
+ * @module @t4r71/dsh-dual-axis/session-store
875
+ */
876
+ /**
877
+ * The settings namespace holding every session's axis pair. It is a Loader entry
878
+ * id (`cordis.patch.yml`), which is what `SettingsForms.write` looks up.
879
+ */
880
+ const SESSION_AXES_NAMESPACE = "dual-axis-sessions";
881
+ /** The single Config field: session id -> axis pair. */
882
+ const SESSION_AXES_FIELD = "axes";
883
+ /**
884
+ * This entry's composition config. The field is `.volatile()` because 0.1.7
885
+ * refuses an entry with no volatile field outright
886
+ * (`packages/settings/settings/src/index.ts:385-386`) and because path writes
887
+ * are refused for every non-volatile path (`:387-389`).
888
+ *
889
+ * `z.dict(z.any())` rather than a per-session object schema: the keys are session
890
+ * ids, which no schema can enumerate, and the VALUES are validated by
891
+ * {@link parseSessionAxesField}, where an unreadable axis becomes a throw rather
892
+ * than an invented boundary — the same rule the row's own axes follow.
893
+ *
894
+ * Annotated rather than inferred: `z.dict`'s output names `Dict` from
895
+ * `@deepseek-ai/cosmokit`, and declaration emit refuses a type it can only reach
896
+ * through a nested `node_modules` path (TS2883). The annotation states the same
897
+ * type through that package's own entry.
898
+ */
899
+ const Config$1 = z.object({ axes: z.dict(z.any()).default({}).volatile() });
900
+ /**
901
+ * The pair {@link SessionAxesStore.getOr} answers for a session with no stored
902
+ * record, and nothing else.
903
+ *
904
+ * It is deliberately NOT what a session with no record is held to any more:
905
+ * every live read goes through {@link SessionAxesStore.ensure}, which answers the
906
+ * deployment's seed ({@link seedAxesFor}), so the pair in force is the pair the
907
+ * session was created with rather than the built-in one. Reaching for this
908
+ * constant on a decision path would hold a session to a boundary the settings
909
+ * page never chose — narrower than an `all` default, wider than a `deny` one.
910
+ * @see SessionAxesStore.getOr
911
+ */
912
+ const DEFAULT_AXES = {
913
+ read: DEFAULT_READ_SCOPE,
914
+ write: DEFAULT_WRITE_SCOPE
915
+ };
916
+ /**
917
+ * The axis pair a session with no stored record is REPAIRED to — the same seed a
918
+ * newly created session is pinned with.
919
+ *
920
+ * Repairing to the standing pair instead (`DEFAULT_AXES`) would be narrower but wrong: a
921
+ * session whose creation-time write was lost would keep the built-in defaults
922
+ * for the rest of its life, and the settings page's seed would never reach it.
923
+ * Repairing to the seed keeps `pin`'s retry semantics intact — the retry just
924
+ * happens on the next touch of the session instead of only at creation.
925
+ *
926
+ * The seed is computed from two synchronous reads: the settings row's
927
+ * `read`/`write`/`defaultGroups` (seeds for a session that has none of its own)
928
+ * and, for a subagent child, the parent's pair AS IT STANDS NOW. A parent with
929
+ * no record of its own yields `undefined` from {@link inheritedAxes}, which is
930
+ * the same answer `pin` gets, so parent and child agree in that case too.
931
+ * @param ctx - host context; carries the settings service the seed is read from.
932
+ * @param session - the session to seed.
933
+ * @param fallback - the composing plugin's own config, used only when no settings
934
+ * service is mounted (where the namespace cannot be written anyway).
935
+ * @returns the pair a newly created session would have been pinned with.
936
+ */
937
+ function seedAxesFor(ctx, session, fallback) {
938
+ const section = declaredSection(ctx);
939
+ const inherited = inheritedAxes(ctx, session);
940
+ if (section === void 0) return seedGroupReferences(inherited ?? fallback?.axes ?? DEFAULT_AXES, fallback?.groups ?? []);
941
+ return seedPair(section, inherited);
942
+ }
943
+ /**
944
+ * The pure core of {@link seedAxesFor}: one settings row value, plus the pair a
945
+ * subagent child inherits, into the pair a session with no record is seeded with.
946
+ *
947
+ * It is TOTAL on purpose, and the client half mirrors it step for step
948
+ * (`sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`, pinned by
949
+ * `tests/seed-parity.spec.ts`): an unreadable row, and an unreadable inherited
950
+ * pair, answer {@link DEFAULT_AXES}. That is also what a live read answers — see
951
+ * {@link SessionAxesStore.ensure} — so the pair a picker shows for a session with
952
+ * no record and the pair the host enforces are one value rather than two.
953
+ * @param row - the settings row's value, untrusted: a human may have edited it.
954
+ * @param inherited - the parent's pair for a subagent child, or `undefined`.
955
+ * @returns the seed pair.
956
+ */
957
+ function seedPair(row, inherited) {
958
+ try {
959
+ return seedGroupReferences(inherited === void 0 ? axesOf(row) : pairOf(inherited), defaultGroupIds(row));
960
+ } catch {
961
+ return DEFAULT_AXES;
962
+ }
963
+ }
964
+ /**
965
+ * Re-normalize a pair another reader produced.
966
+ *
967
+ * A pair this package stored is already normalized; a hand-edited document's is
968
+ * not, and the inherited pair the client half reads comes straight off the wire.
969
+ * Running both through the same validation is what makes the two halves agree on
970
+ * the bytes, not merely on the kinds.
971
+ * @param pair - the untrusted pair.
972
+ * @returns the normalized pair.
973
+ * @throws When either member is not a legal axis value.
974
+ */
975
+ function pairOf(pair) {
976
+ return {
977
+ read: normalizeScope("read", pair.read),
978
+ write: normalizeScope("write", pair.write)
979
+ };
980
+ }
981
+ /**
982
+ * The pair a **subagent child session** inherits, or `undefined` when this is not
983
+ * a child or its parent holds no record.
984
+ *
985
+ * A child does not read the settings page: 0.1.7 deliberately seeds a child with
986
+ * its parent's explicit per-session override, and the parent's STORED pair is the
987
+ * only copy of that override this package has. Returning `undefined` (rather than
988
+ * inventing one) leaves the caller on the settings seed, which is what the child
989
+ * would have received had the parent never overridden anything — and is exactly
990
+ * the pair an un-recorded parent is itself held to.
991
+ *
992
+ * The record is looked up by the id the child's header names. The parent's own
993
+ * Session object is NOT required: requiring it made the answer depend on whether
994
+ * the parent happened to be resident in this process, and the client half — which
995
+ * reads the same document by the same id — cannot observe residency, so the two
996
+ * would disagree for a child whose parent is not materialized.
997
+ * @param ctx - host context carrying the axis store.
998
+ * @param session - the session whose parent to read.
999
+ * @returns the parent's pair, or `undefined`.
1000
+ */
1001
+ function inheritedAxes(ctx, session) {
1002
+ if (session.header.origin !== "subagent") return void 0;
1003
+ const parentId = session.header.parentSession;
1004
+ if (parentId === void 0) return void 0;
1005
+ return sessionAxesStore(ctx).get(String(parentId));
1006
+ }
1007
+ /** Milliseconds before attempt N is issued, N counted from zero. */
1008
+ const AXES_WRITE_BACKOFF_MS = [
1009
+ 0,
1010
+ 25,
1011
+ 75,
1012
+ 200
1013
+ ];
1014
+ /**
1015
+ * A whole-document write was refused because another writer moved the settings
1016
+ * document between this writer's read and its write.
1017
+ *
1018
+ * It is thrown, never swallowed: the axis a person just picked is not in force
1019
+ * unless it was stored, and reporting success for a lost write would leave the
1020
+ * fence enforcing a range the picker does not show.
1021
+ */
1022
+ var SessionAxesConflictError = class extends Error {
1023
+ /** Stable machine code for callers that map failures to their own taxonomy. */
1024
+ code = "DUAL_AXIS_AXES_CONFLICT";
1025
+ /** The namespace whose write was refused. */
1026
+ ns = SESSION_AXES_NAMESPACE;
1027
+ /** Attempts made before giving up. */
1028
+ attempts;
1029
+ /**
1030
+ * @param cause - the last conflict the settings service raised.
1031
+ * @param attempts - attempts made before giving up.
1032
+ */
1033
+ constructor(cause, attempts) {
1034
+ super("dual-axis: the settings document changed under this writer " + String(attempts) + " times while storing a session axis pair; nothing was written", { cause });
1035
+ this.name = "SessionAxesConflictError";
1036
+ this.attempts = attempts;
1037
+ }
1038
+ };
1039
+ /**
1040
+ * Whether two narrowing reports say the same thing, so a republish that changes
1041
+ * nothing does not write the document.
1042
+ * @param left - one report, absent when nothing was ever published.
1043
+ * @param right - the other, absent under the same condition.
1044
+ * @returns whether both report the same removal.
1045
+ */
1046
+ function sameNarrowing(left, right) {
1047
+ if (left === void 0 || right === void 0) return false;
1048
+ return left.narrowed === right.narrowed && left.lostUnbounded === right.lostUnbounded && left.droppedRoots.length === right.droppedRoots.length && left.droppedRoots.every((root) => right.droppedRoots.includes(root));
1049
+ }
1050
+ /**
1051
+ * Whether a value is a plain data object.
1052
+ * @param value - the candidate.
1053
+ * @returns whether it is an object that is not null and not an array.
1054
+ */
1055
+ function isRecord(value) {
1056
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1057
+ }
1058
+ /**
1059
+ * Read one stored record into an axis pair, or throw naming the member that is
1060
+ * unreadable.
1061
+ * @param id - the session id the record belongs to, for the error message.
1062
+ * @param value - the untrusted record.
1063
+ * @returns the validated pair.
1064
+ * @throws When either member is not a legal axis value.
1065
+ */
1066
+ function parseSessionAxesRecord(id, value) {
1067
+ if (!isRecord(value)) throw new Error("dual-axis: the stored axes of session " + JSON.stringify(id) + " must be an object");
1068
+ const narrowing = parseNarrowing(value.narrowing);
1069
+ return {
1070
+ read: normalizeScope("read", value.read ?? DEFAULT_READ_SCOPE),
1071
+ write: normalizeScope("write", value.write ?? DEFAULT_WRITE_SCOPE),
1072
+ ...narrowing.narrowed ? { narrowing } : {}
1073
+ };
1074
+ }
1075
+ /**
1076
+ * Read the whole stored field. A human may have edited the settings document, so
1077
+ * an unreadable entry throws instead of being dropped: dropping it would silently
1078
+ * widen that session back to the deployment defaults.
1079
+ * @param value - the untrusted `axes` field.
1080
+ * @returns every stored pair, by session id.
1081
+ * @throws When the field is not an object of readable records.
1082
+ */
1083
+ function parseSessionAxesField(value) {
1084
+ if (value === void 0 || value === null) return {};
1085
+ if (!isRecord(value)) throw new Error("dual-axis: the stored session axes field must be an object");
1086
+ const parsed = {};
1087
+ for (const [id, record] of Object.entries(value)) parsed[id] = parseSessionAxesRecord(id, record);
1088
+ return parsed;
1089
+ }
1090
+ /**
1091
+ * The stored pair as this package spells it everywhere else. The stored record is
1092
+ * already a pair; this exists so the store's return type is the shared one and a
1093
+ * caller cannot start depending on the record's identity.
1094
+ * @param record - the stored pair.
1095
+ * @returns the same pair.
1096
+ */
1097
+ function scopesOf(record) {
1098
+ return {
1099
+ read: record.read,
1100
+ write: record.write
1101
+ };
1102
+ }
1103
+ /**
1104
+ * The read and write face of {@link SESSION_AXES_NAMESPACE}, plus the sweep that
1105
+ * keeps it from growing with the sessions that no longer exist.
1106
+ *
1107
+ * Reads go through `settings.describe()`, the same projection the settings page
1108
+ * renders from, and are memoized on the namespace's `revision` — which 0.1.7 bumps
1109
+ * exactly when that entry's stored profile override changes
1110
+ * (`packages/settings/settings/src/index.ts:311-317`). A decision path therefore
1111
+ * pays one `describe()` per document change, not one per tool dispatch.
1112
+ */
1113
+ var SessionAxesStore = class {
1114
+ ctx;
1115
+ options;
1116
+ cachedRevision;
1117
+ cachedRecords = {};
1118
+ /** Parsed records by the JSON spelling that produced them. */
1119
+ bySpelling = /* @__PURE__ */ new Map();
1120
+ /**
1121
+ * Session ids with a repair write outstanding (or already landed) since the
1122
+ * record was found missing. Prevents one repair per tool dispatch while the
1123
+ * whole-document write is in flight; cleared when a write FAILS so the next
1124
+ * touch retries instead of leaving the session on the defaults forever.
1125
+ */
1126
+ repairing = /* @__PURE__ */ new Set();
1127
+ /**
1128
+ * The last narrowing report this store handed to a write, by session id. A
1129
+ * decision path re-resolves on every touch, so this is what keeps it from
1130
+ * queueing the same document write over and over; it is cleared when a write
1131
+ * lands (or fails), never on a read.
1132
+ */
1133
+ narrowingMemo = /* @__PURE__ */ new Map();
1134
+ /** Sessions with a narrowing write outstanding right now. */
1135
+ narrowingWriting = /* @__PURE__ */ new Set();
1136
+ /**
1137
+ * @param ctx - host context; the settings service is looked up, never injected,
1138
+ * so a composition without settings still loads this package's fence.
1139
+ * @param options - retry policy and the sleep used between attempts.
1140
+ */
1141
+ constructor(ctx, options = {}) {
1142
+ this.ctx = ctx;
1143
+ this.options = {
1144
+ attempts: options.attempts ?? 4,
1145
+ backoffMs: options.backoffMs ?? AXES_WRITE_BACKOFF_MS,
1146
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => {
1147
+ setTimeout(resolve, ms);
1148
+ }))
1149
+ };
1150
+ }
1151
+ /**
1152
+ * This namespace's current settings row, or `undefined` when no settings
1153
+ * service is mounted or the entry is not composed.
1154
+ * @returns the descriptor carrying the value and the revision.
1155
+ */
1156
+ descriptor() {
1157
+ const settings = this.ctx.get("settings");
1158
+ if (settings === void 0) return void 0;
1159
+ return settings.describe().find((row) => row.ns === SESSION_AXES_NAMESPACE);
1160
+ }
1161
+ /**
1162
+ * Every stored pair, by session id.
1163
+ * @returns the parsed field.
1164
+ * @throws When the document carries an unreadable record.
1165
+ */
1166
+ records() {
1167
+ const descriptor = this.descriptor();
1168
+ if (descriptor === void 0) return {};
1169
+ if (descriptor.revision === this.cachedRevision) return this.cachedRecords;
1170
+ const value = isRecord(descriptor.value) ? descriptor.value[SESSION_AXES_FIELD] : void 0;
1171
+ const spelling = JSON.stringify(value ?? null);
1172
+ let records = this.bySpelling.get(spelling);
1173
+ if (records === void 0) {
1174
+ records = parseSessionAxesField(value);
1175
+ this.bySpelling.set(spelling, records);
1176
+ }
1177
+ this.cachedRevision = descriptor.revision;
1178
+ this.cachedRecords = records;
1179
+ return records;
1180
+ }
1181
+ /**
1182
+ * One session's stored pair.
1183
+ * @param sessionId - the session whose axes to read.
1184
+ * @returns the pair, or `undefined` when this session has no record yet.
1185
+ */
1186
+ get(sessionId) {
1187
+ const record = this.records()[sessionId];
1188
+ return record === void 0 ? void 0 : scopesOf(record);
1189
+ }
1190
+ /**
1191
+ * One session's stored pair, or the built-in defaults.
1192
+ *
1193
+ * The answer for a caller that has no session to build a seed from, and the only
1194
+ * remaining reader of {@link DEFAULT_AXES}. Every live path reads {@link ensure}
1195
+ * instead, whose un-recorded answer is the session's own seed.
1196
+ * @param sessionId - the session whose axes to read.
1197
+ * @returns the pair in force.
1198
+ */
1199
+ getOr(sessionId) {
1200
+ return this.get(sessionId) ?? DEFAULT_AXES;
1201
+ }
1202
+ /**
1203
+ * One session's pair, with an un-recorded session scheduled for repair.
1204
+ *
1205
+ * This is the read every LIVE touch point uses (the model-facing prompt, the
1206
+ * read fence, and `/axis`). A session that has a record answers it; a session
1207
+ * that has none answers {@link seedAxesFor} — the pair `pin` writes for a newly
1208
+ * created session — and that ONE value is both this read's answer and what the
1209
+ * scheduled repair stores. The answer and the write therefore cannot disagree,
1210
+ * and a session created under a saved default runs under that default from its
1211
+ * FIRST turn instead of waiting for the record to land. The record is what
1212
+ * answers from the moment it lands.
1213
+ *
1214
+ * An un-recorded session is only REPAIRED when it has been used
1215
+ * ({@link hasContent}). Until then the seed is recomputed on every read, so the
1216
+ * settings row stays live: a session the workspace picker reopens before anyone
1217
+ * has sent anything follows the settings page, which is the whole point of not
1218
+ * having a record yet. The moment a turn lands, the next read repairs the
1219
+ * session and it freezes — the same seed it answered with, computed from the row
1220
+ * as it then stood.
1221
+ *
1222
+ * The seed is a thunk because only the caller can build it (it needs the
1223
+ * session, and the composing plugin's config for a settings-less host). It is
1224
+ * evaluated exactly once per un-recorded read — those reads are a prompt
1225
+ * assembly, a tool dispatch and a pick, never a hot loop — and it must have no
1226
+ * side effect, because its value IS this read's answer.
1227
+ * @param sessionId - the session whose axes to read.
1228
+ * @param seed - builds the pair a repair persists AND this read answers; never
1229
+ * called when a record exists.
1230
+ * @param frozen - whether the session has been used, so its pair must be
1231
+ * written rather than recomputed. Defaults to `true`: a caller that cannot
1232
+ * judge the session's content keeps the pre-existing freeze-on-first-touch
1233
+ * behaviour, which is the safe direction — it never widens a live session's
1234
+ * axes behind the person's back.
1235
+ * @returns the pair in force right now.
1236
+ */
1237
+ ensure(sessionId, seed, frozen = true) {
1238
+ const standing = this.get(sessionId);
1239
+ if (standing !== void 0) return standing;
1240
+ let seeded;
1241
+ try {
1242
+ seeded = seed();
1243
+ } catch {
1244
+ if (frozen) this.scheduleRepair(sessionId, seed);
1245
+ return DEFAULT_AXES;
1246
+ }
1247
+ if (frozen) this.scheduleRepair(sessionId, () => seeded);
1248
+ return seeded;
1249
+ }
1250
+ /**
1251
+ * Publish the narrowing a live read just resolved, when the record does not
1252
+ * already carry it.
1253
+ *
1254
+ * This is how the client half learns what the read axis removed from the write
1255
+ * range without re-implementing the path algebra: it reads this member out of
1256
+ * the same document its two dropdowns already read. The value comes from the
1257
+ * very resolution the prompt and the fence used, so the surfaced narrowing and
1258
+ * the enforced one are one computation rather than two that must agree.
1259
+ *
1260
+ * Called on every touch of a session that references groups, so an edit to a
1261
+ * group's rules is republished by the next touch instead of waiting for the
1262
+ * record to be rewritten for another reason. A session with no record is left
1263
+ * alone: its axes are recomputed from the settings row on every read, and a
1264
+ * store of its own would freeze that. Writes are fire-and-forget — a caller is
1265
+ * a synchronous decision path and its answer must not depend on a document
1266
+ * write.
1267
+ * @param sessionId - the session whose narrowing to publish.
1268
+ * @param narrowing - the value {@link resolveEffectiveAxes} returned for it.
1269
+ */
1270
+ refreshNarrowing(sessionId, narrowing) {
1271
+ const record = this.records()[sessionId];
1272
+ if (record === void 0) return;
1273
+ const stored = record.narrowing;
1274
+ if (stored === void 0 && !narrowing.narrowed) return;
1275
+ if (sameNarrowing(stored, narrowing)) {
1276
+ this.narrowingMemo.delete(sessionId);
1277
+ return;
1278
+ }
1279
+ if (sameNarrowing(this.narrowingMemo.get(sessionId), narrowing)) return;
1280
+ this.narrowingMemo.set(sessionId, narrowing);
1281
+ this.writeNarrowing(sessionId, narrowing);
1282
+ }
1283
+ /**
1284
+ * Store one narrowing report for a session that already has a record, at most
1285
+ * once per value.
1286
+ *
1287
+ * A failed write only forgets what was attempted: the next touch republishes
1288
+ * the same report, and the dropdowns keep showing the last stored one instead
1289
+ * of nothing. It never touches the axes, so a refresh that loses its race
1290
+ * cannot revert a pick.
1291
+ * @param sessionId - the session to update.
1292
+ * @param narrowing - the report to store.
1293
+ */
1294
+ writeNarrowing(sessionId, narrowing) {
1295
+ if (this.narrowingWriting.has(sessionId)) return;
1296
+ this.narrowingWriting.add(sessionId);
1297
+ Promise.resolve().then(async () => {
1298
+ const record = this.records()[sessionId];
1299
+ if (record === void 0) return;
1300
+ const stored = record.narrowing;
1301
+ if (stored === void 0 && !narrowing.narrowed) return;
1302
+ if (sameNarrowing(stored, narrowing)) return;
1303
+ const { narrowing: _retired, ...axes } = record;
1304
+ await this.replaceRecords({
1305
+ ...this.records(),
1306
+ [sessionId]: {
1307
+ ...axes,
1308
+ ...narrowing.narrowed ? { narrowing } : {}
1309
+ }
1310
+ });
1311
+ }).catch((error) => {
1312
+ this.ctx.logger?.warn("dual-axis: could not store the narrowing of session \"%s\"; the dropdowns keep the last stored report", sessionId);
1313
+ this.ctx.logger?.warn(error);
1314
+ }).finally(() => {
1315
+ this.narrowingWriting.delete(sessionId);
1316
+ this.narrowingMemo.delete(sessionId);
1317
+ });
1318
+ }
1319
+ /**
1320
+ * Store one session's pair, whole-document, and report a lost write loudly.
1321
+ *
1322
+ * A single `replace` of the field: removal needs the whole map anyway, and a
1323
+ * per-id path write cannot prune. Every attempt re-reads the revision, so an
1324
+ * edit that landed between the read and the write is retried against the value
1325
+ * it produced instead of being overwritten from a stale snapshot.
1326
+ * @param sessionId - the session the pair belongs to.
1327
+ * @param axes - the pair to store.
1328
+ * @throws {SessionAxesConflictError} when every attempt lost the revision race.
1329
+ * @throws When no settings service serves this namespace.
1330
+ */
1331
+ async set(sessionId, axes, narrowing) {
1332
+ await this.replaceRecords({
1333
+ ...this.records(),
1334
+ [sessionId]: {
1335
+ read: axes.read,
1336
+ write: axes.write,
1337
+ ...narrowing === void 0 || !narrowing.narrowed ? {} : { narrowing }
1338
+ }
1339
+ });
1340
+ if (narrowing !== void 0) this.narrowingMemo.set(sessionId, narrowing);
1341
+ }
1342
+ /**
1343
+ * Persist one un-recorded session's seed, at most once until it lands.
1344
+ *
1345
+ * Fire-and-forget on purpose: every caller is a synchronous decision path
1346
+ * (a tool dispatch, a prompt assembly) that cannot await a whole-document
1347
+ * write, and none of them may fail because the repair could not be stored —
1348
+ * the answer that read already gave stands either way. A failed repair is
1349
+ * logged and re-armed.
1350
+ * @param sessionId - the session to repair.
1351
+ * @param seed - builds the pair to persist; may throw on an unreadable document.
1352
+ */
1353
+ scheduleRepair(sessionId, seed) {
1354
+ if (this.repairing.has(sessionId)) return;
1355
+ this.repairing.add(sessionId);
1356
+ Promise.resolve().then(async () => {
1357
+ if (this.get(sessionId) !== void 0) return;
1358
+ await this.set(sessionId, seed());
1359
+ this.repairing.delete(sessionId);
1360
+ }).catch((error) => {
1361
+ this.repairing.delete(sessionId);
1362
+ this.ctx.logger?.warn("dual-axis: could not store the axes of session \"%s\"; it stays on the default pair", sessionId);
1363
+ this.ctx.logger?.warn(error);
1364
+ });
1365
+ }
1366
+ /**
1367
+ * Prune records whose session no longer exists.
1368
+ *
1369
+ * The deletion signal is the persistence layer itself: a session whose id is
1370
+ * absent from `sessionPersistence.list()` has no stored log. `session/disposed`
1371
+ * is NOT that signal — residency churn disposes a session whose file is still on
1372
+ * disk, and dropping its axes there would silently reset a session that is about
1373
+ * to be resumed.
1374
+ * @returns the ids dropped, or `undefined` when the sweep cannot judge (no
1375
+ * persistence service mounted, or a listing failed): an unjudgeable sweep
1376
+ * removes nothing.
1377
+ */
1378
+ async sweep() {
1379
+ const records = this.records();
1380
+ const ids = Object.keys(records);
1381
+ if (ids.length === 0) return {
1382
+ removed: [],
1383
+ before: 0
1384
+ };
1385
+ const persistence = this.ctx.get("sessionPersistence");
1386
+ if (persistence === void 0) return void 0;
1387
+ let live;
1388
+ try {
1389
+ const listed = await persistence.list();
1390
+ live = new Set(listed.map((snapshot) => String(snapshot.header.id)));
1391
+ } catch (error) {
1392
+ this.ctx.logger?.warn("dual-axis: could not list stored sessions, keeping every axis record");
1393
+ this.ctx.logger?.warn(error);
1394
+ return;
1395
+ }
1396
+ const kept = {};
1397
+ const removed = [];
1398
+ for (const id of ids) {
1399
+ const record = records[id];
1400
+ if (record === void 0) continue;
1401
+ if (live.has(id)) kept[id] = record;
1402
+ else removed.push(id);
1403
+ }
1404
+ if (removed.length === 0) return {
1405
+ removed: [],
1406
+ before: ids.length
1407
+ };
1408
+ await this.replaceRecords(kept);
1409
+ return {
1410
+ removed,
1411
+ before: ids.length
1412
+ };
1413
+ }
1414
+ /**
1415
+ * Replace the whole field, retrying while the revision fence refuses it.
1416
+ * @param records - the complete next field.
1417
+ * @throws {SessionAxesConflictError} when every attempt was refused.
1418
+ */
1419
+ async replaceRecords(records) {
1420
+ const settings = this.ctx.get("settings");
1421
+ const entryExists = this.descriptor() !== void 0;
1422
+ if (settings === void 0 || !entryExists) throw new Error("dual-axis: no configurable plugin entry \"dual-axis-sessions\"; the session axis store cannot be written");
1423
+ let conflict;
1424
+ for (let attempt = 0; attempt < this.options.attempts; attempt += 1) {
1425
+ const backoff = this.options.backoffMs[attempt] ?? this.options.backoffMs[this.options.backoffMs.length - 1] ?? 0;
1426
+ if (backoff > 0) await this.options.sleep(backoff);
1427
+ const revision = this.descriptor()?.revision;
1428
+ try {
1429
+ await settings.replace(SESSION_AXES_NAMESPACE, { [SESSION_AXES_FIELD]: records }, revision);
1430
+ return;
1431
+ } catch (error) {
1432
+ conflict = error;
1433
+ }
1434
+ }
1435
+ throw new SessionAxesConflictError(conflict, this.options.attempts);
1436
+ }
1437
+ };
1438
+ /** Stores by host context, so every consumer of one process shares one cache. */
1439
+ const stores = /* @__PURE__ */ new WeakMap();
1440
+ /**
1441
+ * The store of one host context. Consumers hold no service of their own: the
1442
+ * fence and the prompt must keep working in a composition that never mounted the
1443
+ * settings service, where the store simply has nothing to read.
1444
+ * @param ctx - host context.
1445
+ * @returns the shared store.
1446
+ */
1447
+ function sessionAxesStore(ctx) {
1448
+ const existing = stores.get(ctx);
1449
+ if (existing !== void 0) return existing;
1450
+ const created = new SessionAxesStore(ctx);
1451
+ stores.set(ctx, created);
1452
+ return created;
1453
+ }
1454
+ /**
1455
+ * This entry's plugin body.
1456
+ *
1457
+ * The entry exists for its Config: `SettingsForms.describe` projects the schemas
1458
+ * of LOADED entries, so the session axis store has no namespace to live in until
1459
+ * a Loader row activates with this plugin. The body itself does nothing — every
1460
+ * read and write goes through {@link SessionAxesStore}, which the composing
1461
+ * plugin owns.
1462
+ * @returns nothing.
1463
+ */
1464
+ async function apply$1() {}
1465
+ Object.assign(apply$1, { Config: Config$1 });
1466
+ Object.defineProperty(apply$1, "name", {
1467
+ value: "dual-axis-sessions",
1468
+ configurable: true
1469
+ });
1470
+ //#endregion
1471
+ //#region lib/types/scope-prompt.js
1472
+ /**
1473
+ * The model-facing text of the two access axes: ONE source for both the
1474
+ * runtime-context paragraph the model reads before it acts and the refusal a
1475
+ * fence returns when it acts anyway.
1476
+ *
1477
+ * Why one module: the paragraph and the refusal must state the same range. Two
1478
+ * hand-written copies drift, and the drift is invisible — the model would plan
1479
+ * against one boundary in its context and be denied by a different one. Both
1480
+ * callers therefore call {@link renderAxisRange} for the range sentence and
1481
+ * share {@link NO_BYPASS} and {@link WIDENING_EXIT} verbatim; neither string is
1482
+ * spelled anywhere else in this package.
1483
+ *
1484
+ * Both callers feed it only session-log facts plus the group definitions the
1485
+ * log's ids resolve against: the axis preset is the `dual-axis/scopes` fold (the
1486
+ * projection unit), the workspace root is the session header's `cwd`, and the
1487
+ * group library is the settings row's `groups` field. Nothing here reads ambient
1488
+ * state of its own, so the rendered text is reconstructable from the log and
1489
+ * that library.
1490
+ *
1491
+ * @module @t4r71/dsh-dual-axis/scope-prompt
1492
+ */
1493
+ /** Render one path list for the model; an empty list is stated, never left blank. */
1494
+ function renderRoots(roots) {
1495
+ return roots.length === 0 ? "(none)" : JSON.stringify(roots);
1496
+ }
1497
+ /**
1498
+ * Render one axis as the concrete places it permits and forbids, with the
1499
+ * configured entries already resolved to canonical absolute roots.
1500
+ *
1501
+ * A model plans against directories, not at mode names: `workspace` alone
1502
+ * leaves it guessing whether the platform temp areas count, and `custom`
1503
+ * leaves it guessing which paths were added or removed. {@link resolveScope}
1504
+ * answers both, so this prints its result instead of the axis value.
1505
+ *
1506
+ * A `custom` axis whose entries cannot be resolved (a foreign or hand-edited
1507
+ * log carrying a relative path, which `ScopeConfigError` refuses) is stated as
1508
+ * permitting nothing rather than throwing: the paragraph must not fail the
1509
+ * whole assembly, and a range that cannot be evaluated must never read as a
1510
+ * wider one.
1511
+ * @param axis - the axis value in force.
1512
+ * @param workspaceRoot - the session workspace root a `workspace` and `custom` axis resolve against.
1513
+ * @returns the range sentence, e.g. `kind custom; allowed roots: ["C:\\ws"]; denied roots: ["C:\\ws\\secret"] (a denied path always wins)`.
1514
+ */
1515
+ function renderAxisRange(axis, workspaceRoot) {
1516
+ let resolved;
1517
+ try {
1518
+ resolved = resolveScope(axis, { workspaceRoot });
1519
+ } catch (error) {
1520
+ const reason = error instanceof Error ? error.message : String(error);
1521
+ return `kind ${axis.kind}; the configured entries could not be resolved (${reason}), so no path is known to be allowed`;
1522
+ }
1523
+ const allowed = resolved.unbounded ? "every path on this host except the denied roots below" : renderRoots(resolved.allow);
1524
+ const denied = resolved.deny.length === 0 ? renderRoots(resolved.deny) : `${renderRoots(resolved.deny)} (a denied path always wins)`;
1525
+ return `kind ${axis.kind}; allowed roots: ${allowed}; denied roots: ${denied}`;
1526
+ }
1527
+ /**
1528
+ * The sentence reporting what the write-never-exceeds-read invariant removed.
1529
+ *
1530
+ * Stated because the removal is otherwise invisible: a rule group that grants
1531
+ * write access to a directory the read axis does not cover grants nothing, and a
1532
+ * user who cannot see that concludes the rule was ignored. Empty when the
1533
+ * intersection removed nothing, so a session that narrowed nothing is not
1534
+ * warned about a narrowing that did not happen.
1535
+ * @param narrowing - what the resolution removed from the write axis.
1536
+ * @returns the sentence, or the empty string when nothing was removed.
1537
+ */
1538
+ function renderNarrowing(narrowing) {
1539
+ if (!narrowing.narrowed) return "";
1540
+ const parts = [];
1541
+ if (narrowing.droppedRoots.length > 0) parts.push(`the read axis removed ${renderRoots(narrowing.droppedRoots)} from it`);
1542
+ if (narrowing.lostUnbounded) parts.push("it was unbounded, and the read range is now its entire bound");
1543
+ return "The read axis narrowed this write range: " + parts.join(", and ") + ". A write axis or rule group naming a path outside the read range grants nothing there — the write is refused, not silently allowed.";
1544
+ }
1545
+ /**
1546
+ * The notice stating that a session's axis preset could not be resolved, so no
1547
+ * path is known to be allowed.
1548
+ *
1549
+ * Rendered both into the prompt and into a refusal, from this one source: an
1550
+ * unresolvable reference — a rule group deleted while a session still references
1551
+ * it — must fail closed everywhere it is met, and must say which id is missing
1552
+ * rather than behaving like a group that allows nothing in particular.
1553
+ * @param problem - the resolution failure, naming the offending id.
1554
+ * @returns the model-facing notice.
1555
+ */
1556
+ function unresolvedAxesNotice(problem) {
1557
+ return "This session's file access axes could not be resolved, so no path is known to be allowed: " + problem + ". The reference is refused rather than read as an empty rule group, because a rule that silently does nothing is worse than a refusal. The only compliant moves are: (1) complete the task inside the allowed range above, or (2) ask the user to widen that axis for this session — the settings page lists the dual-axis row, and the two dropdowns above the composer switch the same two axes.";
1558
+ }
1559
+ /**
1560
+ * The refusal a fence returns for one path, built from the same range sentence
1561
+ * the prompt uses.
1562
+ * @param input - the refused path, the axis that refused it, and the root it resolves against.
1563
+ * @returns the model-facing refusal text.
1564
+ */
1565
+ function scopeRefusal(input) {
1566
+ const narrowing = input.axis === "write" && input.narrowing !== void 0 ? renderNarrowing(input.narrowing) : "";
1567
+ return `Access denied: ${JSON.stringify(input.displayPath)} is outside this session's ${input.axis} axis. ${input.axis} axis in force — ${renderAxisRange(input.scope, input.workspaceRoot)}. ` + (narrowing === "" ? "" : narrowing + " ") + "This is not a limitation of the tool you called: switching to another tool, spawning a child process, writing a script, or calling a command tool does not make the path allowed — reaching it by any other route is still a violation of the session rule. The fence is also not one layer, but the layers differ per axis: the read axis, and the allow/deny PATH entries of the effective write range, are enforced at the tool layer, and bind only the tools that layer sees. The BASE tier of the effective write range — the narrower of the two axes' bases — is enforced a second time below the tool layer: it is mirrored onto the session sandbox mode that the command tools themselves run under, so a \"workspace\" or \"deny\" base keeps holding for a shell command. An effective write range based on \"all\" mirrors to no sandbox restriction at all, so nothing below the tool layer holds it. The only compliant moves are: (1) complete the task inside the allowed range above, or (2) ask the user to widen that axis for this session — the settings page lists the dual-axis row, and the two dropdowns above the composer switch the same two axes.";
1568
+ }
1569
+ //#endregion
1570
+ //#region lib/types/read-guard.js
1571
+ /**
1572
+ * 读轴围栏,作为独立入口 `@t4r71/dsh-dual-axis/read-guard`。
1573
+ *
1574
+ * 为什么读轴需要这一层:0.1.7 的文件系统读路径根本不带会话(`dsh-fs-sandbox` 的读方法只有
1575
+ * target 与 signal,只有 writeText / editText 收 per-call policy),所以本包的
1576
+ * `DualAxisFileSystem` 即使挂上去,也永远拿不到要判的那条会话的轴,只能退回部署默认。
1577
+ * 工具派发层是唯一同时拿得到「这次要动的路径」与「当前会话」的地方:
1578
+ *
1579
+ * 1. `tools/pre-execute`:异步 waterfall,拿得到 `exec.agent.session` 与 `exec.arguments`,
1580
+ * 用 `ctx.fs.resolve` 解析真实身份再做包含判定,返回 `{ kind: 'deny', reason }` 短路,
1581
+ * 模型收到的是一条 `Error: <reason>` 的工具结果。
1582
+ * 2. `ctx.tools.guard(fn)`:单调同步兜底,在全部 pre-execute 监听器之后、工具体之前运行,
1583
+ * 只能把调用判得更严。同步因此只有词法判定,只对 deny 列表判否 —— 显式排除的目录,
1584
+ * 身份判定只会把它判得更严,不会判松,所以词法命中 deny 即拒不会误伤。
1585
+ *
1586
+ * 两轴的执行分工,与 0.1.7 已有的执行点不重叠:
1587
+ * - 读轴:没有任何既有执行点,本模块全量执行(deny 优先,再有界 allow)。
1588
+ * - 写轴:三档封闭取值已由本体沙箱按 `sandbox/mode` 执行(镜像的是两根轴 base 里更窄
1589
+ * 的那个,见 `./groups.ts` 的 `narrowerBase`),本模块执行**交集本身的完整范围** ——
1590
+ * 交集由读轴带来的那部分收窄是路径级的,那条 mode 事件装不下,只有本层能让它对它看得
1591
+ * 见的工具成真。
1592
+ *
1593
+ * 判定路径的输入只有三样:这条会话按 id 存在本包存储里的轴预设(base + 自己追加的
1594
+ * allow/deny + 选中的组 id)、按那些 id 现取的组定义,以及这次调用的目标路径。设置页那一行
1595
+ * 的 read/write 默认值与 defaultGroups 是**种子**,本模块一概不读(`./config.ts` 的
1596
+ * `groupLibraryValue` 是唯一的读取出口)。轴读的是 `./session-store.ts` 的存储,
1597
+ * 不是会话投影 —— 轴已经不在会话日志里。
1598
+ *
1599
+ * @module @t4r71/dsh-dual-axis/read-guard
1600
+ */
1601
+ /** 插件名,loader 诊断用。 */
1602
+ const name = "dual-axis-read-guard";
1603
+ /** 构造期就要用的服务:缺一个就装载失败,而不是运行期静默放行。 */
1604
+ const inject = ["fs", "tools"];
1605
+ /** 读入口:参数里的目标路径会被当成读取对象检查。 */
1606
+ const READ_TOOLS = {
1607
+ read: "file_path",
1608
+ read_image: "file_path",
1609
+ grep: "path",
1610
+ glob: "path"
1611
+ };
1612
+ /**
1613
+ * 写入口:只补写轴排除表。写的基础档位由本体沙箱按 mode 执行,重复执行它会与本体
1614
+ * 对临时区、平台可写根的既有让步打架。
1615
+ */
1616
+ const WRITE_TOOLS = {
1617
+ write: "file_path",
1618
+ edit: "file_path",
1619
+ str_replace_editor: "path"
1620
+ };
1621
+ /** 装载期校验的配置。 */
1622
+ const Config = z.object({
1623
+ enabled: z.boolean().default(true),
1624
+ readTools: z.array(z.string()).default(Object.keys(READ_TOOLS)),
1625
+ writeTools: z.array(z.string()).default(Object.keys(WRITE_TOOLS))
1626
+ });
1627
+ /**
1628
+ * 一条会话自己的轴预设:读本包自有的会话轴存储(`./session-store.ts`),
1629
+ * 存储里没有这条会话时用这条会话的**种子**(设置页那一行 + 父会话当刻的记录)——
1630
+ * 与新会话被钉下的、以及记录缺席时被补写的都是同一份计算。预设里带着组 id,组体不在这里
1631
+ * —— 组定义按 id 现取,见 {@link boundaryOf}。
1632
+ * @param ctx - 插件上下文,承载设置服务(存储的读路径)。
1633
+ * @param session - 发起这次调用的会话。
1634
+ * @returns 该会话的轴预设。
1635
+ */
1636
+ function presetOf(ctx, session) {
1637
+ return sessionAxesStore(ctx).ensure(String(session.header.id), () => seedAxesFor(ctx, session), hasContent(session));
1638
+ }
1639
+ /**
1640
+ * 一次判定的实际生效范围:会话预设 + 按 id 现取的组定义 → 展开 → 写轴 ∩ 读轴。
1641
+ *
1642
+ * 组定义只在预设真的引用了组时才去读设置页:引用不到组的会话(绝大多数)因此每次判定
1643
+ * 都不碰设置服务,而引用到的会话每次判定都读当刻那一份,改一个组立刻作用于所有引用它的
1644
+ * 会话,不需要重启也不需要重开会话。
1645
+ *
1646
+ * 解算失败(引用了库中不存在的组 id、组库本身读不懂)返回 `ok: false`,由调用方拒绝这次
1647
+ * 访问:既不能当成空组放行,也不能抛出去把工具派发整条链打断。
1648
+ * @param ctx - 插件上下文,承载轴存储与设置服务。
1649
+ * @param session - 发起这次调用的会话。
1650
+ * @param root - 这条会话的工作区根,`workspace` 基档按它解析。
1651
+ * @returns 实际生效的那一对轴,或失败原因。
1652
+ */
1653
+ function boundaryOf(ctx, session, root) {
1654
+ const preset = presetOf(ctx, session);
1655
+ const resolved = resolveEffectiveAxes(preset, referencedGroupIds(preset).length === 0 ? void 0 : groupLibraryValue(declaredSection(ctx)), { workspaceRoot: root });
1656
+ return resolved.ok ? {
1657
+ ok: true,
1658
+ axes: resolved.axes
1659
+ } : {
1660
+ ok: false,
1661
+ problem: resolved.problem
1662
+ };
1663
+ }
1664
+ /**
1665
+ * 从一次调用的参数里取出目标路径拼写。
1666
+ * @param tool - 工具名。
1667
+ * @param args - 工具参数。
1668
+ * @param table - 被检查的工具表。
1669
+ * @param allowed - 配置里启用的工具名集合。
1670
+ * @returns 目标路径拼写,表外工具或字段缺失时为 undefined(=不检查)。
1671
+ */
1672
+ function spelledPath(tool, args, table, allowed) {
1673
+ if (!allowed.has(tool)) return void 0;
1674
+ const field = table[tool];
1675
+ if (field === void 0) return void 0;
1676
+ if (args === null || typeof args !== "object") return void 0;
1677
+ const value = args[field];
1678
+ if (typeof value !== "string" || value.trim().length === 0) return void 0;
1679
+ return value;
1680
+ }
1681
+ /**
1682
+ * 异步包含判定:词法快路径命中的直接用,拼写不同时回落到 provider 自己的 `ctx.fs.contains`
1683
+ * —— 它按真实文件系统身份比较,认 Windows 8.3 别名与大小写。
1684
+ * @param ctx - 插件上下文。
1685
+ * @param targetKey - 目标的规范化身份键。
1686
+ * @param root - 参与比较的根。
1687
+ * @returns 目标是否落在根之下。
1688
+ */
1689
+ async function contained(ctx, targetKey, root) {
1690
+ if (isLexicallyUnder(targetKey, root)) return true;
1691
+ const rootTarget = await ctx.fs.resolve(root);
1692
+ const target = await ctx.fs.resolve(targetKey);
1693
+ return ctx.fs.contains(rootTarget, target);
1694
+ }
1695
+ /**
1696
+ * deny 优先于 allow,unbounded 只是「没有上界」,不豁免 deny —— 与 scopeContains 同序。
1697
+ * @param ctx - 插件上下文。
1698
+ * @param scope - 已解析的轴取值。
1699
+ * @param targetKey - 目标的规范化身份键。
1700
+ * @returns 该轴是否放行这个目标。
1701
+ */
1702
+ async function permitted(ctx, scope, targetKey) {
1703
+ for (const root of scope.deny) if (await contained(ctx, targetKey, root)) return false;
1704
+ if (scope.unbounded) return true;
1705
+ for (const root of scope.allow) if (await contained(ctx, targetKey, root)) return true;
1706
+ return false;
1707
+ }
1708
+ /**
1709
+ * The refusal one dispatch returns for one target. The text itself lives in
1710
+ * `./scope-prompt.ts` and is the SAME source the runtime-context paragraph
1711
+ * renders from, so the range the model read before it acted and the range it is
1712
+ * denied by cannot disagree.
1713
+ * @param target - the refused target, with the axis that refused it.
1714
+ * @param displayPath - the path as the model should see it.
1715
+ * @param scope - the refusing axis's value.
1716
+ * @param narrowing - what the read axis removed from the write range, for a write refusal.
1717
+ * @returns the model-facing refusal text.
1718
+ */
1719
+ function refusal(target, displayPath, scope, narrowing) {
1720
+ return scopeRefusal({
1721
+ displayPath,
1722
+ axis: target.axis,
1723
+ scope,
1724
+ workspaceRoot: target.root,
1725
+ ...narrowing === void 0 ? {} : { narrowing }
1726
+ });
1727
+ }
1728
+ /**
1729
+ * 取出这次调用的目标,表外工具返回 undefined。
1730
+ * @param execution - 一次工具执行。
1731
+ * @param readAllowed - 启用的读入口。
1732
+ * @param writeAllowed - 启用的写入口。
1733
+ * @returns 目标,或 undefined。
1734
+ */
1735
+ function targetOf(execution, readAllowed, writeAllowed) {
1736
+ const session = execution.agent?.session;
1737
+ if (session === void 0) return void 0;
1738
+ const root = session.header.cwd;
1739
+ if (root === void 0) return void 0;
1740
+ const read = spelledPath(execution.name, execution.arguments, READ_TOOLS, readAllowed);
1741
+ if (read !== void 0) return {
1742
+ axis: "read",
1743
+ spelled: read,
1744
+ session,
1745
+ root
1746
+ };
1747
+ const write = spelledPath(execution.name, execution.arguments, WRITE_TOOLS, writeAllowed);
1748
+ if (write !== void 0) return {
1749
+ axis: "write",
1750
+ spelled: write,
1751
+ session,
1752
+ root
1753
+ };
1754
+ }
1755
+ /**
1756
+ * 注册两层的轴围栏。
1757
+ * @param ctx - 插件上下文;两个注册都挂在它上面,随它一起卸载。
1758
+ * @param config - 装载期已校验的配置。
1759
+ */
1760
+ async function apply(ctx, config = {}) {
1761
+ if (!(config.enabled ?? true)) return;
1762
+ const readAllowed = new Set(config.readTools ?? Object.keys(READ_TOOLS));
1763
+ const writeAllowed = new Set(config.writeTools ?? Object.keys(WRITE_TOOLS));
1764
+ ctx.on("tools/pre-execute", async (exec, next) => {
1765
+ const target = targetOf(exec, readAllowed, writeAllowed);
1766
+ if (target === void 0) return next();
1767
+ const boundary = boundaryOf(ctx, target.session, target.root);
1768
+ if (!boundary.ok) return {
1769
+ kind: "deny",
1770
+ reason: unresolvedAxesNotice(boundary.problem)
1771
+ };
1772
+ const axis = boundary.axes[target.axis];
1773
+ const scope = resolveScope(axis, { workspaceRoot: target.root });
1774
+ if (scope.unbounded && scope.deny.length === 0) return next();
1775
+ const resolved = await ctx.fs.resolve(target.spelled, {
1776
+ cwd: target.root,
1777
+ signal: exec.signal
1778
+ });
1779
+ if (await permitted(ctx, scope, resolved.targetKey)) return next();
1780
+ return {
1781
+ kind: "deny",
1782
+ reason: refusal(target, resolved.displayPath, axis, boundary.axes.narrowing)
1783
+ };
1784
+ });
1785
+ const guard = (execution) => {
1786
+ const target = targetOf(execution, readAllowed, writeAllowed);
1787
+ if (target === void 0) return void 0;
1788
+ const boundary = boundaryOf(ctx, target.session, target.root);
1789
+ if (!boundary.ok) return unresolvedAxesNotice(boundary.problem);
1790
+ const axis = boundary.axes[target.axis];
1791
+ const scope = resolveScope(axis, { workspaceRoot: target.root });
1792
+ if (scope.deny.length === 0) return void 0;
1793
+ const targetKey = resolve(target.root, target.spelled);
1794
+ for (const denyRoot of scope.deny) if (isLexicallyUnder(targetKey, denyRoot)) return refusal(target, target.spelled, axis, boundary.axes.narrowing);
1795
+ };
1796
+ ctx.tools.guard(guard);
1797
+ }
1798
+ /**
1799
+ * 默认导出必须自带 inject / Config / name:Loader.unwrapExports 返回 exports.default 并丢掉
1800
+ * 兄弟具名导出(vendor/loader/src/index.ts:201-208),而 registry 只读 plugin.inject
1801
+ * (vendor/cordis/src/registry.ts:330)—— 挂不上就会不等服务先激活,首次读 ctx.tools 抛错。
1802
+ */
1803
+ var read_guard_default = Object.assign(apply, {
1804
+ inject,
1805
+ Config
1806
+ });
1807
+ Object.defineProperty(apply, "name", {
1808
+ value: name,
1809
+ configurable: true
1810
+ });
1811
+ //#endregion
1812
+ export { Config, read_guard_default as default, inject, name };