@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.
package/lib/index.js ADDED
@@ -0,0 +1,2591 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { dirname, sep } from "node:path";
3
+ import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
4
+ import { setSandboxMode } from "@deepseek-ai/dsh-sandbox-policy";
5
+ import { CommandDefinitionId } from "@deepseek-ai/dsh-commands/brand";
6
+ import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
7
+ import { FsError } from "@deepseek-ai/dsh-fs";
8
+ import { stat } from "node:fs/promises";
9
+ //#region lib/types/axis.js
10
+ /**
11
+ * The read/write ACCESS AXES of the file sandbox: the per-axis vocabulary a
12
+ * session selects and the lossless mapping onto the legacy single `mode`.
13
+ *
14
+ * This is the 0.1.7 port of the algebra upstream deleted. 0.1.6 shipped it as
15
+ * `@deepseek-ai/dsh-sandbox/access` (152 lines) and
16
+ * `@deepseek-ai/dsh-sandbox/scope` (152 lines); 0.1.7 has neither symbol nor
17
+ * file, so the package carries its own copy and keeps the 0.1.6 semantics
18
+ * verbatim: four kinds, `custom` = base + allow - deny with deny winning.
19
+ *
20
+ * One axis answers "which absolute paths may this execution touch". `deny`
21
+ * permits nothing, `workspace` permits the calling session's workspace root
22
+ * plus the platform temp areas, `all` is the whole host filesystem, and
23
+ * `custom` names a BASE kind plus absolute path ADDITIONS and REMOVALS —
24
+ * path lists, not patterns.
25
+ *
26
+ * `mode` REMAINS the write axis's persistence spelling. The session-format
27
+ * whitelist pins its literal values, so this module keeps `mode` derivable
28
+ * from the write scope in both directions instead of replacing it:
29
+ * {@link scopeOfMode} and {@link modeOfScope} are the two halves of that
30
+ * bijection, and `custom` maps onto the mode implied by its own base.
31
+ *
32
+ * @module @t4r71/dsh-dual-axis/axis
33
+ */
34
+ /** Every {@link AxisScopeKind}, for option advertisement and untrusted-value validation. */
35
+ const AXIS_KINDS = [
36
+ "deny",
37
+ "workspace",
38
+ "all",
39
+ "custom"
40
+ ];
41
+ /** Every {@link AxisBase}, for option advertisement and untrusted-value validation. */
42
+ const AXIS_BASES = [
43
+ "deny",
44
+ "workspace",
45
+ "all"
46
+ ];
47
+ /** The default READ axis: every mode permitted reading before the axes existed, so the whole host. */
48
+ const DEFAULT_READ_SCOPE = { kind: "all" };
49
+ /** The default WRITE axis: the session workspace plus the platform temp areas. */
50
+ const DEFAULT_WRITE_SCOPE = { kind: "workspace" };
51
+ /**
52
+ * Whether an untrusted runtime value names one of the three `SandboxMode` members.
53
+ *
54
+ * The TYPE says `SandboxMode`; the session LOG says whatever a past writer put
55
+ * there. A replayed or foreign log can carry a mode this build never wrote, and
56
+ * a fold that trusts it would take the whole projected axis cell down with it.
57
+ * This guard is the boundary that turns 'the log claims a mode' into 'the log
58
+ * claims a mode this build understands': callers read a false answer as 'names
59
+ * no mode' and keep the axis value they already had.
60
+ * @param value - the untrusted value from a session-log payload.
61
+ * @returns true when the value is a `SandboxMode`.
62
+ */
63
+ function isSandboxMode(value) {
64
+ return value === "read-only" || value === "workspace-write" || value === "danger-full-access";
65
+ }
66
+ /**
67
+ * The write scope a legacy `mode` means.
68
+ * @param mode - the sandbox mode.
69
+ * @returns the equivalent write scope.
70
+ */
71
+ function scopeOfMode(mode) {
72
+ switch (mode) {
73
+ case "read-only": return { kind: "deny" };
74
+ case "workspace-write": return { kind: "workspace" };
75
+ case "danger-full-access": return { kind: "all" };
76
+ }
77
+ }
78
+ /**
79
+ * The mode an axis BASE implies — the shared half of {@link modeOfScope}. The
80
+ * three-value mode set is closed, so every base spells exactly one mode.
81
+ * @param base - the axis base to spell.
82
+ * @returns the mode that base has always been persisted as.
83
+ */
84
+ function modeOfAxisBase(base) {
85
+ switch (base) {
86
+ case "deny": return "read-only";
87
+ case "workspace": return "workspace-write";
88
+ case "all": return "danger-full-access";
89
+ }
90
+ }
91
+ /**
92
+ * The legacy `mode` a write scope is spelled as. `custom` resolves through its
93
+ * own base: the mode names the containment the fence must not exceed, while the
94
+ * scope's additions and removals ride beside it.
95
+ * @param scope - the write axis value.
96
+ * @returns the mode that spells this scope's base.
97
+ */
98
+ function modeOfScope(scope) {
99
+ return scope.kind === "custom" ? modeOfAxisBase(scope.base) : modeOfAxisBase(scope.kind);
100
+ }
101
+ /**
102
+ * Read the axis pair a resolved policy carries, falling back to the defaults.
103
+ * The policy's `mode` is authoritative for the write axis's BASE: a policy
104
+ * that carries no write axes writes exactly what its mode always meant, so
105
+ * every pre-existing policy keeps its exact behavior.
106
+ *
107
+ * 0.1.7's `SandboxExecutionPolicy` has no `readScope` / `writeScope`
108
+ * members (only `mode`, `workspaceRoot`, `sessionId?`), so the axes arrive
109
+ * through the extra argument this package's own fence passes. The
110
+ * `mode`-derived fallback keeps the function total for the upstream type.
111
+ * @param policy - the resolved policy (supplies `mode`).
112
+ * @param axes - the session's axis pair, when the caller holds one.
113
+ * @returns both axes in force.
114
+ */
115
+ function effectiveScopes(policy, axes) {
116
+ return {
117
+ read: axes?.read ?? DEFAULT_READ_SCOPE,
118
+ write: axes?.write ?? scopeOfMode(policy.mode)
119
+ };
120
+ }
121
+ /**
122
+ * Whether a policy's `mode` still spells its write scope's base — the
123
+ * invariant every construction site must preserve. A custom write scope whose
124
+ * base disagrees with `mode` would enforce containment the session never chose.
125
+ * @param policy - the policy to check.
126
+ * @param axes - the session's axis pair, when the caller holds one.
127
+ * @returns true when the two agree.
128
+ */
129
+ function isModeConsistent(policy, axes) {
130
+ return policy.mode === modeOfScope(effectiveScopes(policy, axes).write);
131
+ }
132
+ //#endregion
133
+ //#region lib/types/scope.js
134
+ /**
135
+ * The shared path-range ALGEBRA behind both access axes: one pure evaluation
136
+ * from an {@link AxisScope} to the canonical allow and deny root sets the
137
+ * fence consumes.
138
+ *
139
+ * This is the 0.1.7 port of 0.1.6's `@deepseek-ai/dsh-sandbox/scope`
140
+ * (152 lines), reduced to what a fence needs and made source-agnostic:
141
+ *
142
+ * - `workspace` derives its roots from 0.1.7's own
143
+ * `writableRoots(policy)` (`@deepseek-ai/dsh-sandbox`, re-exported from
144
+ * `packages/sandbox/sandbox/src/roots.ts:52`), so the fence agrees with
145
+ * the Seatbelt profile and the write fence by construction.
146
+ * - Containment itself is NOT evaluated here. 0.1.6 took the enforcement
147
+ * layer's `contains` predicate as a parameter; this port keeps that shape
148
+ * but narrows it to the SYNCHRONOUS lexical predicate the pure tests use,
149
+ * and the filesystem-identity fallback lives in the fence
150
+ * (`fs-fence.ts`), which is where the canonical target key exists.
151
+ *
152
+ * `resolveScope` returns the sets; the fence applies them with deny-wins
153
+ * precedence through {@link scopeContains}.
154
+ *
155
+ * @module @t4r71/dsh-dual-axis/scope
156
+ */
157
+ /** Thrown when a configured `custom` scope entry cannot name an absolute path. */
158
+ var ScopeConfigError = class extends Error {
159
+ entry;
160
+ value;
161
+ constructor(entry, value) {
162
+ super(`sandbox scope: \`${entry}\` entry ${JSON.stringify(value)} must be a non-empty absolute path`);
163
+ this.entry = entry;
164
+ this.value = value;
165
+ this.name = "ScopeConfigError";
166
+ }
167
+ };
168
+ /** Canonicalize one configured custom entry, failing closed on anything that cannot name an absolute host path. */
169
+ function canonicalEntry(value, entry) {
170
+ if (value.trim().length === 0 || !isAbsoluteSpelling(value)) throw new ScopeConfigError(entry, value);
171
+ return canonicalPath(value);
172
+ }
173
+ /**
174
+ * Whether a configured path is spelled absolutely on this host. Both POSIX
175
+ * (`/x`) and Windows (`C:\\x`, `\\\\server\\share`) spellings are accepted
176
+ * because a policy may be authored for one world and resolved in another; the
177
+ * canonical resolution that follows is what actually binds it to this host.
178
+ * @param path - the configured path spelling.
179
+ * @returns whether the spelling is absolute.
180
+ */
181
+ function isAbsoluteSpelling(path) {
182
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
183
+ }
184
+ /**
185
+ * Evaluate one axis against a workspace root. `deny` permits nothing;
186
+ * `workspace` yields the shared writable roots; `all` is unbounded;
187
+ * `custom` yields its base plus its own additions, with its removals listed
188
+ * separately so the fence can apply them with precedence.
189
+ *
190
+ * A `custom` scope whose base is `all` stays unbounded: "everything except
191
+ * these directories" is still everything-except, so its removals must survive
192
+ * into {@link ResolvedScope.deny} rather than being flattened into an allow
193
+ * list that could not express them.
194
+ * @param scope - the axis value.
195
+ * @param policy - the workspace root `workspace` and `custom` scopes resolve against.
196
+ * @returns the evaluated range.
197
+ * @throws {ScopeConfigError} when a `custom` entry is not a non-empty absolute path.
198
+ */
199
+ function resolveScope(scope, policy) {
200
+ if (scope.kind !== "custom") return resolveBase(scope.kind, policy);
201
+ const base = resolveBase(scope.base, policy);
202
+ const allow = [...base.allow, ...scope.allow.map((entry) => canonicalEntry(entry, "allow"))];
203
+ const deny = scope.deny.map((entry) => canonicalEntry(entry, "deny"));
204
+ return {
205
+ unbounded: base.unbounded,
206
+ allow: dedupe(allow),
207
+ deny: dedupe(deny)
208
+ };
209
+ }
210
+ /** Evaluate a base (or closed) kind — the shared half of {@link resolveScope}. */
211
+ function resolveBase(base, policy) {
212
+ switch (base) {
213
+ case "deny": return {
214
+ unbounded: false,
215
+ allow: [],
216
+ deny: []
217
+ };
218
+ case "workspace": return {
219
+ unbounded: false,
220
+ allow: dedupe(writableRoots({
221
+ mode: "workspace-write",
222
+ workspaceRoot: policy.workspaceRoot
223
+ })),
224
+ deny: []
225
+ };
226
+ case "all": return {
227
+ unbounded: true,
228
+ allow: [],
229
+ deny: []
230
+ };
231
+ }
232
+ }
233
+ /** Stable-order dedupe: keeps the first spelling of each value. */
234
+ function dedupe(values) {
235
+ return [...new Set(values)];
236
+ }
237
+ /**
238
+ * Whether `target` is the root itself or lies beneath it, by canonical
239
+ * spelling. Case-insensitive on Windows, matching that filesystem's
240
+ * convention.
241
+ * @param target - canonical target path.
242
+ * @param root - canonical root path.
243
+ * @param caseSensitive - whether lexical comparison preserves case; defaults to the host convention.
244
+ * @returns whether the target is the root or a descendant of it.
245
+ */
246
+ function isLexicallyUnder(target, root, caseSensitive = process.platform !== "win32") {
247
+ const comparableTarget = caseSensitive ? target : target.toLowerCase();
248
+ const comparableRoot = caseSensitive ? root : root.toLowerCase();
249
+ if (comparableTarget === comparableRoot) return true;
250
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
251
+ return comparableTarget.startsWith(prefix);
252
+ }
253
+ /**
254
+ * Whether `targetKey` is permitted by an evaluated scope. Removal wins over
255
+ * addition: a deny root containing the target refuses it even when an allow
256
+ * root — or an unbounded axis — would otherwise permit it.
257
+ * @param scope - the evaluated scope.
258
+ * @param targetKey - the target's canonical identity key (the resolved path).
259
+ * @param contains - the containment predicate; defaults to {@link isLexicallyUnder}.
260
+ * @returns whether the scope permits the target.
261
+ */
262
+ function scopeContains(scope, targetKey, contains = isLexicallyUnder) {
263
+ for (const root of scope.deny) if (contains(targetKey, root)) return false;
264
+ if (scope.unbounded) return true;
265
+ for (const root of scope.allow) if (contains(targetKey, root)) return true;
266
+ return false;
267
+ }
268
+ //#endregion
269
+ //#region lib/types/config.js
270
+ /**
271
+ * The dual-axis settings surface: the 0.1.7 spelling of the section 0.1.6
272
+ * registered through `ctx.settings.installSection`.
273
+ *
274
+ * 0.1.7 removed both `installSection` and `settings.register(ns, schema)`:
275
+ * `SettingsForms.describe()` projects exactly the Config schemas of LOADED
276
+ * Loader entries (`packages/settings/settings/src/index.ts:302-340`) and
277
+ * `write()` refuses a namespace with no matching entry
278
+ * (`:382-384`, `No configurable plugin entry`). The namespace IS the entry
279
+ * id, so this package's own row id in `cordis.patch.yml` — `dual-axis` — is
280
+ * the settings namespace. 0.1.6's separate `sandbox-axis` namespace name is
281
+ * therefore retired.
282
+ *
283
+ * 0.1.7 also added the volatile gate: a field that is not beneath a
284
+ * `.volatile()` node is neither projected into the form
285
+ * (`packages/settings/settings/src/schema.ts:37-47`) nor writable
286
+ * (`:74-78`), and an entry with no volatile field at all is refused outright
287
+ * (`index.ts:385-386`). Both axes are marked volatile here.
288
+ *
289
+ * The schema is deliberately `z.any()` per axis, exactly as in 0.1.6:
290
+ * schemastery's union types strip the `custom` branch's `base/allow/deny`
291
+ * payload, which would silently degrade a configured custom axis into an axis
292
+ * carrying no paths. Shape validation is {@link normalizeScope}'s job.
293
+ *
294
+ * @module @t4r71/dsh-dual-axis/config
295
+ */
296
+ /**
297
+ * This package's row id in `cordis.patch.yml`. It doubles as the 0.1.7
298
+ * settings namespace (the Loader entry id) and, prefixed with the package
299
+ * name, as the `plugins.row.config` slot entry key the client half uses.
300
+ */
301
+ const DUAL_AXIS_ROW_ID = "dual-axis";
302
+ /**
303
+ * The 0.1.6 settings namespace name, kept only so a migration can recognize
304
+ * the retired spelling. Nothing registers under it in 0.1.7.
305
+ */
306
+ const RETIRED_SETTINGS_NAMESPACE = "sandbox-axis";
307
+ /**
308
+ * The composition config's schema. Both axes are `.volatile()` and
309
+ * `z.any()` — see the module docstring for why each is required.
310
+ *
311
+ * Not annotated `z<Config>`: `.volatile()` widens the field's inferred type
312
+ * to schemastery's `Volatile<>` accessor, which the plain `Config` interface
313
+ * does not describe. The repo's own volatile Configs
314
+ * (`packages/core/agent-default-model/src/index.ts:51-55`) are inferred the
315
+ * same way, and {@link axesOf} re-establishes the declared shape.
316
+ */
317
+ const Config = z.object({
318
+ read: z.any().volatile(),
319
+ write: z.any().volatile(),
320
+ groups: z.array(z.any()).default([]).volatile(),
321
+ defaultGroups: z.array(z.string()).default([]).volatile()
322
+ });
323
+ /**
324
+ * Collect one `custom` axis's path list: every entry must be a non-empty
325
+ * absolute path.
326
+ * @param label - the axis name used in the error message.
327
+ * @param entry - the list name used in the error message (`allow` or `deny`).
328
+ * @param value - the untrusted list value.
329
+ * @returns the validated path list.
330
+ * @throws When the value is not a string array, or holds a non-absolute path.
331
+ */
332
+ function pathList(label, entry, value) {
333
+ if (value === void 0) return [];
334
+ 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`);
335
+ 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`);
336
+ return [...value];
337
+ }
338
+ /**
339
+ * Coerce one axis value from a config document (which a human may have edited)
340
+ * into a legal {@link AxisScope}, throwing rather than guessing.
341
+ *
342
+ * An unreadable axis must not become some other, wider or narrower grant:
343
+ * throwing fails the row's mount or the settings write on the spot instead of
344
+ * proceeding under an invented boundary. This is 0.1.6's `normalizeScope`,
345
+ * unchanged.
346
+ * @param label - the axis name used in error messages (`read` or `write`).
347
+ * @param value - the untrusted axis value.
348
+ * @returns the validated axis value.
349
+ * @throws When the value is not one of the four kinds, or a custom entry is not absolute.
350
+ */
351
+ function normalizeScope(label, value) {
352
+ if (typeof value !== "object" || value === null) throw new Error(`dual-axis: ${label} must be an access-axis object, received ${JSON.stringify(value)}`);
353
+ const candidate = value;
354
+ 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(", ")})`);
355
+ if (candidate.kind !== "custom") return { kind: candidate.kind };
356
+ 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)}`);
357
+ return {
358
+ kind: "custom",
359
+ base: candidate.base,
360
+ groups: groupIds(label, candidate.groups),
361
+ allow: pathList(label, "allow", candidate.allow),
362
+ deny: pathList(label, "deny", candidate.deny)
363
+ };
364
+ }
365
+ /**
366
+ * Collect one `custom` axis's rule-group references. Only the speaker matters
367
+ * here — an id is a name the settings page resolves, so this checks that the
368
+ * list is a list of non-empty names and nothing more; whether the name exists is
369
+ * decided at the moment of use ({@link resolveEffectiveAxes}), where a missing
370
+ * one can be refused instead of quietly dropped.
371
+ * @param label - the axis name used in the error message.
372
+ * @param value - the untrusted `groups` value.
373
+ * @returns the validated id list.
374
+ * @throws When the value is not an array of non-empty strings.
375
+ */
376
+ function groupIds(label, value) {
377
+ if (value === void 0) return [];
378
+ 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`);
379
+ return [...new Set(value)];
380
+ }
381
+ /**
382
+ * Whether two axes describe the same boundary. Compared member by member
383
+ * rather than by reference: every re-parse builds fresh axis objects, and a
384
+ * custom axis's two path lists carry order as part of their value.
385
+ * @param left - one axis.
386
+ * @param right - another axis.
387
+ * @returns whether both describe the same axis.
388
+ */
389
+ function sameScope(left, right) {
390
+ if (left.kind !== right.kind) return false;
391
+ if (left.kind !== "custom" || right.kind !== "custom") return true;
392
+ const sameList = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
393
+ return left.base === right.base && sameList(left.groups ?? [], right.groups ?? []) && sameList(left.allow, right.allow) && sameList(left.deny, right.deny);
394
+ }
395
+ /**
396
+ * Read both axes out of one settings/config value.
397
+ *
398
+ * The parameter is UNTRUSTED on purpose: the same function serves the
399
+ * composition layer's parsed Config, the settings service's projected
400
+ * descriptor (an `unknown` on the wire between two packages), and a
401
+ * hand-edited document, and none of those three is guaranteed to carry the
402
+ * declared shape. Both axes therefore pass through {@link normalizeScope},
403
+ * which is where an illegal value becomes a throw instead of an invented
404
+ * boundary; a missing side falls back to the built-in default axis.
405
+ * @param section - the section's current resolved value, whatever carries it.
406
+ * @returns both axes.
407
+ * @throws When either axis's shape is illegal.
408
+ */
409
+ function axesOf(section) {
410
+ const declared = section === null || typeof section !== "object" ? {} : section;
411
+ return {
412
+ read: normalizeScope("read", unwrapVolatile(declared.read) ?? DEFAULT_READ_SCOPE),
413
+ write: normalizeScope("write", unwrapVolatile(declared.write) ?? DEFAULT_WRITE_SCOPE)
414
+ };
415
+ }
416
+ /**
417
+ * Unwrap one config field's value.
418
+ *
419
+ * A `.volatile()` field is handed to the plugin as a cordis `Volatile<T>`
420
+ * **accessor**, not as `T`: the schema's `get()` re-reads the stored value on
421
+ * every call so an edit applies without remounting the entry. Reading the
422
+ * property directly therefore yields the accessor object, whose `kind` is
423
+ * `undefined` — which {@link normalizeScope} correctly refuses, failing the
424
+ * whole row at mount. The upstream volatile Config
425
+ * (`packages/core/agent-default-model/src/index.ts:68-71`) unwraps the same way.
426
+ *
427
+ * A hand-written config document may also carry the plain value, so both
428
+ * spellings are accepted.
429
+ * @param value - the configured field, as the accessor, as a plain value, or as
430
+ * whatever an untrusted document put under the key.
431
+ * @returns the underlying value, or `undefined` when this field is unset.
432
+ */
433
+ function unwrapVolatile(value) {
434
+ if (value === void 0) return void 0;
435
+ if (typeof value.get === "function") return value.get();
436
+ return value;
437
+ }
438
+ /**
439
+ * This row's current value on the settings page, or `undefined` when no
440
+ * settings service is mounted.
441
+ *
442
+ * This is the ONE read path to that row. It goes through `describe()` rather
443
+ * than the config the plugin instance captured at mount because the loader does
444
+ * not commit a later save back to that instance
445
+ * (`vendor/loader/src/config/entry.ts:162-195`); `describe()` re-projects
446
+ * `entry.fiber.config` and unwraps the volatile accessors on every call
447
+ * (`packages/settings/settings/src/index.ts:319-323` +
448
+ * `packages/settings/settings/src/schema.ts:11`), which is the same path the
449
+ * settings page renders from. A save therefore applies without a restart.
450
+ * @param ctx - host context; the service is looked up, never injected.
451
+ * @returns the row's current value, or `undefined` when unavailable.
452
+ */
453
+ function declaredSection(ctx) {
454
+ return ctx.get("settings")?.describe().find((row) => row.ns === DUAL_AXIS_ROW_ID)?.value;
455
+ }
456
+ /**
457
+ * The group library a decision path may read, and the ONLY part of the settings
458
+ * row one may read for a decision.
459
+ *
460
+ * The row's `read`, `write` and `defaultGroups` fields are seeds for
461
+ * NEWLY CREATED sessions and carry no authority over an existing one; a
462
+ * decision path that read them would let the settings page silently re-scope a
463
+ * running session, which is the drift this package exists to prevent.
464
+ * @param section - the row's current value, as {@link declaredSection} returns it.
465
+ * @returns the untrusted `groups` value, for {@link resolveEffectiveAxes}.
466
+ */
467
+ function groupLibraryValue(section) {
468
+ return section === null || typeof section !== "object" ? void 0 : unwrapVolatile(section.groups);
469
+ }
470
+ /**
471
+ * The group ids a newly created session starts out referencing.
472
+ * @param section - the row's current value, as {@link declaredSection} returns it.
473
+ * @returns the ids, deduplicated; empty when the row declares none.
474
+ * @throws When a declared id is not a non-empty string.
475
+ */
476
+ function defaultGroupIds(section) {
477
+ return groupIds("defaultGroups", section === null || typeof section !== "object" ? void 0 : unwrapVolatile(section.defaultGroups));
478
+ }
479
+ //#endregion
480
+ //#region lib/types/groups.js
481
+ /**
482
+ * RULE GROUPS and THE ONE resolution from a session's axis preset to the ranges
483
+ * actually in force.
484
+ *
485
+ * A rule group is a named fragment of path rules owned by the settings page
486
+ * (`dual-axis`'s `groups` Config field). A session references groups BY ID and
487
+ * never stores their content: ten sessions selecting one group must not become
488
+ * ten copies of it, because copies drift. Resolving an id against the CURRENT
489
+ * library is therefore the only place a group's rules enter a decision, and
490
+ * editing a group takes effect on every session referencing it without a
491
+ * restart.
492
+ *
493
+ * This module is that place, for both axes and for both invariants that ride on
494
+ * them:
495
+ *
496
+ * - **deny wins over allow**, with session-level entries and every selected
497
+ * group merged into ONE allow list and ONE deny list first
498
+ * ({@link resolveEffectiveAxes}), so a group's removal cannot be overridden by
499
+ * a session-level addition.
500
+ * - **write never exceeds read**: the effective write range is the write axis's
501
+ * range INTERSECTED with the read axis's range. A write outside the read range
502
+ * would let an agent overwrite a file it cannot read back, so the intersection
503
+ * is computed here — in the resolution — and never in a user interface.
504
+ *
505
+ * Both the fence and the model-facing prompt call {@link resolveEffectiveAxes},
506
+ * so the range the model is told about and the range it is held to are the same
507
+ * computation over the same inputs: the session's own preset (read and write
508
+ * axes, their own additions and removals, and the group ids they selected) plus
509
+ * the current definitions of those ids. The settings page's `read` / `write`
510
+ * defaults and its `defaultGroups` seed are NOT among those inputs: they are
511
+ * read once, when a session is created.
512
+ *
513
+ * A reference to an id the library does not define is refused, never treated as
514
+ * an empty group: "I excluded it and nothing happened" is the worst failure this
515
+ * surface can have.
516
+ *
517
+ * @module @t4r71/dsh-dual-axis/groups
518
+ */
519
+ /** The narrowing that removed nothing. Exported: it is the stored record's absent value. */
520
+ const NO_NARROWING = {
521
+ narrowed: false,
522
+ droppedRoots: [],
523
+ lostUnbounded: false
524
+ };
525
+ /**
526
+ * Read one stored narrowing report, or throw naming the member that is
527
+ * unreadable.
528
+ *
529
+ * An absent member is the legal "nothing was ever published for this record"
530
+ * state and yields {@link NO_NARROWING}: that is exactly what the fence enforced
531
+ * before this member existed, so the surface shows no narrowing rather than
532
+ * inventing one. Anything PRESENT must be well formed — a report whose root list
533
+ * cannot be read would otherwise be displayed as "nothing was removed".
534
+ * @param value - the untrusted `narrowing` member of a stored record.
535
+ * @returns the validated report.
536
+ * @throws When the member is present and not a well-formed report.
537
+ */
538
+ function parseNarrowing(value) {
539
+ if (value === void 0) return NO_NARROWING;
540
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("dual-axis: a stored narrowing report must be an object");
541
+ const candidate = value;
542
+ if (typeof candidate.narrowed !== "boolean" || typeof candidate.lostUnbounded !== "boolean") throw new Error("dual-axis: a stored narrowing report needs boolean narrowed and lostUnbounded");
543
+ return {
544
+ narrowed: candidate.narrowed,
545
+ droppedRoots: pathList("stored narrowing", "droppedRoots", candidate.droppedRoots),
546
+ lostUnbounded: candidate.lostUnbounded
547
+ };
548
+ }
549
+ /**
550
+ * Parse one group's rules for one axis.
551
+ * @param label - the group and axis, as the error message should name them.
552
+ * @param value - the untrusted `read` or `write` member.
553
+ * @returns the validated rules, or `undefined` when the member is absent.
554
+ * @throws When the member is neither absent nor an object of absolute path lists.
555
+ */
556
+ function axisRules(label, value) {
557
+ if (value === void 0) return void 0;
558
+ if (value === null || typeof value !== "object") throw new Error(`dual-axis: ${label} must be an object carrying allow and/or deny`);
559
+ const candidate = value;
560
+ return {
561
+ allow: pathList(label, "allow", candidate.allow),
562
+ deny: pathList(label, "deny", candidate.deny)
563
+ };
564
+ }
565
+ /**
566
+ * Read one group entry out of the library.
567
+ * @param index - the entry's position, used in error messages.
568
+ * @param value - the untrusted entry.
569
+ * @returns the validated group.
570
+ * @throws When the entry carries no usable id.
571
+ */
572
+ function groupAt(index, value) {
573
+ if (value === null || typeof value !== "object") throw new Error(`dual-axis: rule group #${String(index)} must be an object`);
574
+ const candidate = value;
575
+ if (typeof candidate.id !== "string" || candidate.id.length === 0) throw new Error(`dual-axis: rule group #${String(index)} needs a non-empty string id`);
576
+ const label = "rule group " + JSON.stringify(candidate.id);
577
+ const read = candidate.read === void 0 ? void 0 : axisRules(label + " read", candidate.read);
578
+ const write = candidate.write === void 0 ? void 0 : axisRules(label + " write", candidate.write);
579
+ return {
580
+ id: candidate.id,
581
+ name: typeof candidate.name === "string" && candidate.name.length > 0 ? candidate.name : candidate.id,
582
+ ...read === void 0 ? {} : { read },
583
+ ...write === void 0 ? {} : { write }
584
+ };
585
+ }
586
+ /**
587
+ * Parse the settings page's group library. An unset or null library is the
588
+ * legal "no groups configured" state; anything else must be a well-formed
589
+ * library, because a half-read library would silently drop a removal.
590
+ * @param value - the untrusted `groups` Config value.
591
+ * @returns the parsed library by id, or the sentence explaining why it is unusable.
592
+ */
593
+ function parseLibrary(value) {
594
+ if (value === void 0 || value === null) return {
595
+ ok: true,
596
+ byId: /* @__PURE__ */ new Map()
597
+ };
598
+ if (!Array.isArray(value)) return {
599
+ ok: false,
600
+ problem: "the rule-group library must be an array of groups"
601
+ };
602
+ const byId = /* @__PURE__ */ new Map();
603
+ try {
604
+ for (let index = 0; index < value.length; index += 1) {
605
+ const group = groupAt(index, value[index]);
606
+ if (byId.has(group.id)) return {
607
+ ok: false,
608
+ problem: "the rule-group library defines " + JSON.stringify(group.id) + " more than once"
609
+ };
610
+ byId.set(group.id, group);
611
+ }
612
+ } catch (error) {
613
+ return {
614
+ ok: false,
615
+ problem: error instanceof Error ? error.message : String(error)
616
+ };
617
+ }
618
+ return {
619
+ ok: true,
620
+ byId
621
+ };
622
+ }
623
+ /**
624
+ * The group ids a preset references, in first-seen order and without repeats.
625
+ * Callers use this to skip reading the settings page at all for the sessions
626
+ * that reference no groups.
627
+ * @param preset - the session's own axis preset.
628
+ * @returns the referenced ids.
629
+ */
630
+ function referencedGroupIds(preset) {
631
+ const ids = [];
632
+ for (const axis of [preset.read, preset.write]) {
633
+ if (axis.kind !== "custom") continue;
634
+ for (const id of axis.groups ?? []) if (!ids.includes(id)) ids.push(id);
635
+ }
636
+ return ids;
637
+ }
638
+ /**
639
+ * The ids the settings page's library defines, for validating one command entry
640
+ * before it is recorded.
641
+ *
642
+ * An unusable library yields no ids: every `groups` entry is then refused by
643
+ * name, which is the same fail-closed answer the decision path gives.
644
+ * @param library - the settings page's current `groups` value, untrusted.
645
+ * @returns the defined ids.
646
+ */
647
+ function ruleGroupIds(library) {
648
+ const parsed = parseLibrary(library);
649
+ return parsed.ok ? new Set(parsed.byId.keys()) : /* @__PURE__ */ new Set();
650
+ }
651
+ /**
652
+ * Expand one axis: its own additions and removals first, then those of every
653
+ * group it references, merged into one allow list and one deny list. The result
654
+ * carries no group ids — expansion is the resolution, and a resolved value that
655
+ * still referenced groups could be expanded a second time.
656
+ * @param axis - the axis preset.
657
+ * @param side - which of a group's two rule sets applies.
658
+ * @param byId - the parsed library.
659
+ * @returns the expanded axis.
660
+ */
661
+ function expandAxis(axis, side, byId) {
662
+ if (axis.kind !== "custom") return axis;
663
+ const ids = axis.groups ?? [];
664
+ if (ids.length === 0) return axis;
665
+ const allow = [...axis.allow];
666
+ const deny = [...axis.deny];
667
+ for (const id of ids) {
668
+ const rules = byId.get(id)?.[side];
669
+ if (rules === void 0) continue;
670
+ allow.push(...rules.allow ?? []);
671
+ deny.push(...rules.deny ?? []);
672
+ }
673
+ return {
674
+ kind: "custom",
675
+ base: axis.base,
676
+ allow: [...new Set(allow)],
677
+ deny: [...new Set(deny)]
678
+ };
679
+ }
680
+ /**
681
+ * Attach the group ids a NEW session starts out referencing to the axes that are
682
+ * `custom`, leaving every other axis exactly as declared.
683
+ *
684
+ * The rule is judged per axis, and only an axis whose OWN kind is `custom` can
685
+ * carry a reference: checking the settings row instead (a tick that is not
686
+ * tied to an axis) promoted `read: all` to `read: { kind: 'custom', base: 'all' }`
687
+ * even though no axis was ever set to custom. An axis of one of the three closed
688
+ * kinds has nowhere to put an id and is seeded verbatim — promoting it would
689
+ * report a custom scope the settings page never showed.
690
+ *
691
+ * A group may carry rules for either axis or both, and the session selected it
692
+ * once, so the same list goes on both `custom` axes; the group's `read` rules
693
+ * then apply where they exist and its `write` rules where they exist. Two axes
694
+ * are therefore independent: one may take the references while the other keeps
695
+ * its closed kind.
696
+ * @param axes - the pair a new session is seeded with.
697
+ * @param ids - the settings page's `defaultGroups`.
698
+ * @returns the pair carrying those references.
699
+ */
700
+ function seedGroupReferences(axes, ids) {
701
+ if (ids.length === 0) return axes;
702
+ const attach = (axis) => axis.kind === "custom" ? {
703
+ ...axis,
704
+ groups: [.../* @__PURE__ */ new Set([...axis.groups ?? [], ...ids])]
705
+ } : axis;
706
+ return {
707
+ read: attach(axes.read),
708
+ write: attach(axes.write)
709
+ };
710
+ }
711
+ /**
712
+ * The intersection of two evaluated ranges, as an evaluated range. Exact for
713
+ * canonical directory roots: two roots are either nested — the deeper one is
714
+ * their intersection — or disjoint, and a union of directories intersected with
715
+ * a union of directories is the union of those pairwise intersections.
716
+ * @param left - one evaluated range.
717
+ * @param right - the other evaluated range.
718
+ * @returns the range permitting exactly what both permit.
719
+ */
720
+ function intersectResolved(left, right) {
721
+ const deny = [.../* @__PURE__ */ new Set([...left.deny, ...right.deny])];
722
+ if (left.unbounded && right.unbounded) return {
723
+ unbounded: true,
724
+ allow: [],
725
+ deny
726
+ };
727
+ if (left.unbounded) return {
728
+ unbounded: false,
729
+ allow: [...right.allow],
730
+ deny
731
+ };
732
+ if (right.unbounded) return {
733
+ unbounded: false,
734
+ allow: [...left.allow],
735
+ deny
736
+ };
737
+ const allow = [];
738
+ for (const one of left.allow) for (const other of right.allow) if (isLexicallyUnder(one, other) && !allow.includes(one)) allow.push(one);
739
+ else if (isLexicallyUnder(other, one) && !allow.includes(other)) allow.push(other);
740
+ return {
741
+ unbounded: false,
742
+ allow,
743
+ deny
744
+ };
745
+ }
746
+ /**
747
+ * Spell an evaluated range as an axis value that evaluates back to it. The
748
+ * `deny` base plus the roots themselves is the exact spelling: a `workspace`
749
+ * base would re-add the platform temp areas this range may never have had.
750
+ * @param resolved - the evaluated range.
751
+ * @returns the equivalent axis value.
752
+ */
753
+ function scopeOfResolved(resolved) {
754
+ return resolved.unbounded ? {
755
+ kind: "custom",
756
+ base: "all",
757
+ allow: [],
758
+ deny: [...resolved.deny]
759
+ } : {
760
+ kind: "custom",
761
+ base: "deny",
762
+ allow: [...resolved.allow],
763
+ deny: [...resolved.deny]
764
+ };
765
+ }
766
+ /**
767
+ * The removals in a range that remove something, so two ranges that differ only
768
+ * in irrelevant removals compare equal.
769
+ * @param scope - the evaluated range.
770
+ * @returns the deny roots that would otherwise be permitted.
771
+ */
772
+ function relevantDenies(scope) {
773
+ const permitAll = {
774
+ unbounded: scope.unbounded,
775
+ allow: scope.allow,
776
+ deny: []
777
+ };
778
+ return scope.deny.filter((root) => scopeContains(permitAll, root, isLexicallyUnder));
779
+ }
780
+ /** Whether two evaluated ranges permit exactly the same paths. */
781
+ function sameRange(left, right) {
782
+ const sameSet = (one, other) => one.length === other.length && one.every((value) => other.includes(value));
783
+ if (left.unbounded !== right.unbounded) return false;
784
+ if (left.unbounded) return sameSet(relevantDenies(left), relevantDenies(right));
785
+ return sameSet(left.allow, right.allow) && sameSet(relevantDenies(left), relevantDenies(right));
786
+ }
787
+ /**
788
+ * What the intersection removed: every root the write axis permitted that the
789
+ * effective range does not, counting both roots dropped from the allow list and
790
+ * roots the read axis newly denies.
791
+ * @param before - the write axis's own range.
792
+ * @param after - the effective write range.
793
+ * @returns the narrowing report.
794
+ */
795
+ function narrowingOf(before, after) {
796
+ const lostUnbounded = before.unbounded && !after.unbounded;
797
+ const droppedRoots = [...before.allow.filter((root) => !scopeContains(after, root, isLexicallyUnder)), ...after.deny.filter((root) => !before.deny.includes(root) && scopeContains(before, root, isLexicallyUnder))];
798
+ const unique = [...new Set(droppedRoots)];
799
+ return {
800
+ narrowed: lostUnbounded || unique.length > 0,
801
+ droppedRoots: unique,
802
+ lostUnbounded
803
+ };
804
+ }
805
+ /**
806
+ * Expand one axis against the library, reporting the ids it references and the
807
+ * library does not define.
808
+ * @param axis - the axis preset.
809
+ * @param side - which of a group's two rule sets applies.
810
+ * @param byId - the parsed library.
811
+ * @param missing - collects the undefined ids, in reference order.
812
+ * @returns the expanded axis.
813
+ */
814
+ function expandChecked(axis, side, byId, missing) {
815
+ if (axis.kind === "custom") {
816
+ for (const id of axis.groups ?? []) if (!byId.has(id) && !missing.includes(id)) missing.push(id);
817
+ }
818
+ return expandAxis(axis, side, byId);
819
+ }
820
+ /** The sentence naming the ids a preset references and the library does not define. */
821
+ function missingGroupsProblem(ids) {
822
+ return "this session references rule group(s) " + ids.map((id) => JSON.stringify(id)).join(", ") + " that the rule-group library does not define";
823
+ }
824
+ /**
825
+ * THE resolution: one session's axis preset plus the current group library into
826
+ * the read and write ranges actually in force.
827
+ *
828
+ * The write member of the result is the intersection of the write axis's range
829
+ * with the read axis's. The write axis keeps its own spelling whenever the
830
+ * intersection changed nothing, so a session that references no groups and needs
831
+ * no narrowing is described and enforced exactly as before groups existed.
832
+ *
833
+ * An unresolvable reference or an unusable library returns `ok: false` and no
834
+ * axes: the caller refuses, because resolving a missing id to an empty group
835
+ * would silently drop rules the user configured.
836
+ * @param preset - the session's own axis preset, group ids included.
837
+ * @param library - the settings page's current `groups` value, untrusted.
838
+ * @param policy - the workspace root a `workspace` base resolves against.
839
+ * @returns the effective pair, or the sentence explaining why there is none.
840
+ */
841
+ function resolveEffectiveAxes(preset, library, policy) {
842
+ const parsed = parseLibrary(library);
843
+ if (!parsed.ok) return {
844
+ ok: false,
845
+ problem: parsed.problem
846
+ };
847
+ const missing = [];
848
+ const read = expandChecked(preset.read, "read", parsed.byId, missing);
849
+ const write = expandChecked(preset.write, "write", parsed.byId, missing);
850
+ if (missing.length > 0) return {
851
+ ok: false,
852
+ problem: missingGroupsProblem(missing)
853
+ };
854
+ let readRange;
855
+ let writeRange;
856
+ try {
857
+ readRange = resolveScope(read, policy);
858
+ writeRange = resolveScope(write, policy);
859
+ } catch (error) {
860
+ return {
861
+ ok: false,
862
+ problem: error instanceof Error ? error.message : String(error)
863
+ };
864
+ }
865
+ const intersected = intersectResolved(writeRange, readRange);
866
+ if (sameRange(writeRange, intersected)) return {
867
+ ok: true,
868
+ axes: {
869
+ read,
870
+ write,
871
+ narrowing: NO_NARROWING
872
+ }
873
+ };
874
+ return {
875
+ ok: true,
876
+ axes: {
877
+ read,
878
+ write: scopeOfResolved(intersected),
879
+ narrowing: narrowingOf(writeRange, intersected)
880
+ }
881
+ };
882
+ }
883
+ /**
884
+ * The narrower of two bases, in the containment order `deny` ⊂ `workspace` ⊂
885
+ * `all`.
886
+ *
887
+ * This is the tier the write-never-exceeds-read invariant can express to the
888
+ * layer BELOW the tool layer. That layer is a single closed sandbox mode
889
+ * mirrored from the write axis onto the session's `sandbox/mode`, and it cannot
890
+ * spell a path list; the narrower base is the largest tier both axes contain, so
891
+ * mirroring it keeps the operating-system confinement inside the intersection
892
+ * instead of leaving it at the write axis's own, wider base.
893
+ * @param left - one axis.
894
+ * @param right - the other axis.
895
+ * @returns the narrower base.
896
+ */
897
+ function narrowerBase(left, right) {
898
+ const rank = (scope) => scope.kind === "custom" ? [
899
+ "deny",
900
+ "workspace",
901
+ "all"
902
+ ].indexOf(scope.base) : [
903
+ "deny",
904
+ "workspace",
905
+ "all"
906
+ ].indexOf(scope.kind);
907
+ return rank(left) <= rank(right) ? left.kind === "custom" ? left.base : left.kind : right.kind === "custom" ? right.base : right.kind;
908
+ }
909
+ //#endregion
910
+ //#region lib/types/content.js
911
+ /**
912
+ * Whether a session has been USED yet — the one predicate that decides when its
913
+ * axis pair freezes.
914
+ *
915
+ * A session the workspace picker reopens in the same workspace is the same
916
+ * session id with the same stored record, so the axes it was seeded with at
917
+ * creation are what its two dropdowns show for the rest of its life. That is
918
+ * correct for a session someone has actually worked in; for one nobody has ever
919
+ * exchanged a turn with it is wrong, because the settings row is a live default
920
+ * and the person changing it expects the next conversation to follow.
921
+ *
922
+ * So the record is written when the session is used, not when it is created:
923
+ *
924
+ * - nothing appended beyond the loop's runtime-context snapshot → no record; the
925
+ * seed recomputed from the CURRENT settings row answers every read, so the
926
+ * dropdown follows the settings page in real time;
927
+ * - one real user turn, or an assistant reply → the record is written and the
928
+ * pair freezes;
929
+ * - `/axis` → {@link SessionAxesStore.set} writes unconditionally, so a manual
930
+ * pick always freezes, on an empty session too.
931
+ *
932
+ * ## What counts as "content"
933
+ *
934
+ * Everything except a message whose source is the loop's own runtime-context
935
+ * snapshot. That snapshot is appended by the agent loop on EVERY turn
936
+ * (`packages/core/agent-loop/src/runtime-context.ts`), including the very first
937
+ * one, and it is present before any human input exists — the four events a
938
+ * freshly created session carries are the header, the preset, the mode, and the
939
+ * approval policy. Counting it would make every session look used the moment
940
+ * anything rendered its prompt, which is exactly the state this predicate has to
941
+ * tell apart.
942
+ *
943
+ * The snapshot is identified by its `source.kind`, the discriminant the loop
944
+ * writes and reads back (`isOwned` in that module requires `kind === 'plugin'`;
945
+ * the system-prompt renderer stamps `kind: 'runtime-context'`). Matching on the
946
+ * kind is cheaper and more robust than matching the rendered text, which is
947
+ * localized prose that changes with every contribution.
948
+ *
949
+ * Every other message counts, the skill catalogue and the compaction markers
950
+ * included: each of them means the session has entered a turn, and a session
951
+ * that has entered a turn is one somebody is using.
952
+ *
953
+ * @module @t4r71/dsh-dual-axis/content
954
+ */
955
+ /**
956
+ * The `source.kind` of the loop's per-turn runtime-context snapshot.
957
+ *
958
+ * @see `packages/core/agent-loop/src/runtime-context.ts` — `RuntimeContextProjection.project`
959
+ * builds the message, and the renderer in
960
+ * `packages/core/system-prompt/src/index.ts` stamps this kind on it.
961
+ */
962
+ const RUNTIME_CONTEXT_KIND = "runtime-context";
963
+ /**
964
+ * The event types that can carry content, so a listener can skip the rest without
965
+ * building the session's whole event list.
966
+ *
967
+ * A `system/message` is in here because it only exists once a turn has started,
968
+ * and a started turn is a used session. The prompt is assembled and that event
969
+ * appended BEFORE the human message reaches the log (measured: `system/message`
970
+ * at seq 7, the user turn at seq 8), so a rule that waited for the user message
971
+ * alone would let a tool-less turn finish without freezing anything.
972
+ */
973
+ const CONTENT_EVENT_TYPES = /* @__PURE__ */ new Set([
974
+ "user/message",
975
+ "assistant/message",
976
+ "system/message"
977
+ ]);
978
+ /**
979
+ * Whether a session has content beyond the loop's own runtime-context snapshots.
980
+ *
981
+ * Cheap by construction: it walks the session's existing event array, which is
982
+ * already materialized and cached, without copying, parsing, or allocating per
983
+ * event. It is called on every `ensure` for a session that has no record, and
984
+ * that set is exactly the sessions nobody has used — so the scan is short.
985
+ * @param session - the session to judge.
986
+ * @returns whether this session has been used.
987
+ */
988
+ function hasContent(session) {
989
+ for (const event of session.snapshotEvents()) {
990
+ if (event.type === "user/message") {
991
+ if (event.data.source?.kind === RUNTIME_CONTEXT_KIND) continue;
992
+ return true;
993
+ }
994
+ if (event.type === "assistant/message" || event.type === "system/message") return true;
995
+ }
996
+ return false;
997
+ }
998
+ //#endregion
999
+ //#region lib/types/session-axes.js
1000
+ /**
1001
+ * The write axis's landing place in a session's LOG, and the reason it is the
1002
+ * only one.
1003
+ *
1004
+ * 0.1.6 carried both axes on the \`sandbox/mode\` event (\`readScope\` /
1005
+ * \`writeScope\` members) and wrote them through \`setSandboxScopes\`. 0.1.7's
1006
+ * \`sandbox/mode\` payload is locked to \`{ mode, source }\` in three independent
1007
+ * places — the \`SessionEventMap\` declaration
1008
+ * (\`packages/sandbox/sandbox-policy/src/session-mode.ts:33-38\`), the session-format
1009
+ * validator
1010
+ * (\`packages/session/session-format-v0-to-v1/src/payload-validation.ts:165-168\`),
1011
+ * and the generated schema (\`docs/persistence-schema.json:11132-11147\`) — so the
1012
+ * path-level axis pair cannot ride on it.
1013
+ *
1014
+ * A package-declared event type is not an alternative: \`Session.append\`
1015
+ * (\`packages/core/session/src/index.ts:722-750\`) builds the envelope as exactly
1016
+ * \`{ type, seq, time, data, surfaceOp?, sourceEventSeqs? }\` and takes no envelope
1017
+ * option, so a type outside \`KNOWN_SESSION_EVENT_TYPES\` cannot be marked
1018
+ * \`ignorable\` (the marker exists only on the READ side,
1019
+ * \`packages/session/session-persistence/src/storage-contract.ts:75\`) and a log
1020
+ * carrying it is refused whole. That refusal is why the pair now lives in
1021
+ * \`./session-store.ts\` instead.
1022
+ *
1023
+ * What REMAINS here is the write base's mirror onto \`sandbox/mode\`: a known type,
1024
+ * carrying the closed three-value mode the operating-system layer enforces. The
1025
+ * mirror is the NARROWER of the two axes' bases, never the write axis's own —
1026
+ * the effective write range is the write axis intersected with the read axis, so
1027
+ * mirroring the wider base would leave the layer below the tool fence granting
1028
+ * writes the intersection forbids.
1029
+ *
1030
+ * @module @t4r71/dsh-dual-axis/session-axes
1031
+ */
1032
+ /**
1033
+ * Mirror one session's write base onto 0.1.7's own \`sandbox/mode\` event, so the
1034
+ * inherited write fence and the system-prompt policy section agree with the axis
1035
+ * pair this package stores.
1036
+ *
1037
+ * The mirrored TIER is derived from the two axes' bases only, never from a rule
1038
+ * group's contents, so editing a group cannot leave this event stale. Path-level
1039
+ * narrowing is enforced live, where the group definitions are read.
1040
+ *
1041
+ * The event is appended even when it repeats the standing mode: \`sandbox/mode\`
1042
+ * carries no idempotence contract, and a session that gained axes without a
1043
+ * matching mode record would have its write fence read a stale mode.
1044
+ * @param session - the session the axes belong to.
1045
+ * @param axes - the axis pair in force from this event onward.
1046
+ */
1047
+ function mirrorWriteMode(session, axes) {
1048
+ setSandboxMode(session, modeOfAxisBase(narrowerBase(axes.write, axes.read)));
1049
+ }
1050
+ //#endregion
1051
+ //#region lib/types/session-store.js
1052
+ /**
1053
+ * THE per-session axis store, outside the session log.
1054
+ *
1055
+ * The axes cannot live in a session's own log: this package's own event type is
1056
+ * not in 0.1.7's `KNOWN_SESSION_EVENT_TYPES`, and `Session.append` takes no
1057
+ * envelope option, so the event cannot be marked `ignorable` and a log carrying
1058
+ * it is refused WHOLE by `validateStoredEvents`
1059
+ * (`packages/session/session-persistence/src/storage-contract.ts:75-77`).
1060
+ * A session this package had written to could therefore not be opened at all
1061
+ * after a restart.
1062
+ *
1063
+ * The axes live instead in this package's OWN settings namespace,
1064
+ * `dual-axis-sessions`, partitioned by session id. That namespace is a
1065
+ * separate Loader entry from `dual-axis` because the row's namespace carries the
1066
+ * settings page's read/write/defaultGroups form: an undeclared field inside that
1067
+ * row would be projected into the row's form. This namespace declares exactly one
1068
+ * field and the settings page never renders it.
1069
+ *
1070
+ * `sandbox/mode` REMAINS the write axis's legal landing place in the log — see
1071
+ * `./session-axes.ts`, which mirrors the write base onto it. The log therefore
1072
+ * still records the containment the operating-system layer enforces; what it no
1073
+ * longer records is the path-level axis pair, which is this module's.
1074
+ *
1075
+ * A session with NO record is neither an error state nor a reason to hide a
1076
+ * control: every live decision path reads through {@link SessionAxesStore.ensure},
1077
+ * which answers {@link seedAxesFor} — the deployment seed a newly created session
1078
+ * is pinned with — and hands that SAME value to the ONE repair write it
1079
+ * schedules. Such a session therefore runs under the deployment's seed from its
1080
+ * first read, not under {@link DEFAULT_AXES} until the record lands, and the pair
1081
+ * a picker shows before the record exists is the pair the fence and the prompt
1082
+ * are already enforcing (the client half recomputes that seed from the same row —
1083
+ * see `sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`).
1084
+ * {@link DEFAULT_AXES} is now only the answer {@link SessionAxesStore.getOr} gives
1085
+ * a caller that holds no session to build a seed from.
1086
+ *
1087
+ * A session with no record that has ALSO never been used is a different case, and
1088
+ * it is the one the workspace picker creates every time: reopening the same
1089
+ * workspace reopens the same session id, so freezing it at creation would pin the
1090
+ * axes of a conversation nobody has had to whatever the settings row said that
1091
+ * day. Its record is therefore written when it is USED ({@link hasContent}), not
1092
+ * when it is created: until then every read recomputes the seed from the row as
1093
+ * it stands NOW, and the dropdown follows the settings page in real time.
1094
+ *
1095
+ * @module @t4r71/dsh-dual-axis/session-store
1096
+ */
1097
+ /**
1098
+ * The settings namespace holding every session's axis pair. It is a Loader entry
1099
+ * id (`cordis.patch.yml`), which is what `SettingsForms.write` looks up.
1100
+ */
1101
+ const SESSION_AXES_NAMESPACE = "dual-axis-sessions";
1102
+ /** The single Config field: session id -> axis pair. */
1103
+ const SESSION_AXES_FIELD = "axes";
1104
+ /**
1105
+ * This entry's composition config. The field is `.volatile()` because 0.1.7
1106
+ * refuses an entry with no volatile field outright
1107
+ * (`packages/settings/settings/src/index.ts:385-386`) and because path writes
1108
+ * are refused for every non-volatile path (`:387-389`).
1109
+ *
1110
+ * `z.dict(z.any())` rather than a per-session object schema: the keys are session
1111
+ * ids, which no schema can enumerate, and the VALUES are validated by
1112
+ * {@link parseSessionAxesField}, where an unreadable axis becomes a throw rather
1113
+ * than an invented boundary — the same rule the row's own axes follow.
1114
+ *
1115
+ * Annotated rather than inferred: `z.dict`'s output names `Dict` from
1116
+ * `@deepseek-ai/cosmokit`, and declaration emit refuses a type it can only reach
1117
+ * through a nested `node_modules` path (TS2883). The annotation states the same
1118
+ * type through that package's own entry.
1119
+ */
1120
+ const Config$1 = z.object({ axes: z.dict(z.any()).default({}).volatile() });
1121
+ /**
1122
+ * The pair {@link SessionAxesStore.getOr} answers for a session with no stored
1123
+ * record, and nothing else.
1124
+ *
1125
+ * It is deliberately NOT what a session with no record is held to any more:
1126
+ * every live read goes through {@link SessionAxesStore.ensure}, which answers the
1127
+ * deployment's seed ({@link seedAxesFor}), so the pair in force is the pair the
1128
+ * session was created with rather than the built-in one. Reaching for this
1129
+ * constant on a decision path would hold a session to a boundary the settings
1130
+ * page never chose — narrower than an `all` default, wider than a `deny` one.
1131
+ * @see SessionAxesStore.getOr
1132
+ */
1133
+ const DEFAULT_AXES = {
1134
+ read: DEFAULT_READ_SCOPE,
1135
+ write: DEFAULT_WRITE_SCOPE
1136
+ };
1137
+ /**
1138
+ * The axis pair a session with no stored record is REPAIRED to — the same seed a
1139
+ * newly created session is pinned with.
1140
+ *
1141
+ * Repairing to the standing pair instead (`DEFAULT_AXES`) would be narrower but wrong: a
1142
+ * session whose creation-time write was lost would keep the built-in defaults
1143
+ * for the rest of its life, and the settings page's seed would never reach it.
1144
+ * Repairing to the seed keeps `pin`'s retry semantics intact — the retry just
1145
+ * happens on the next touch of the session instead of only at creation.
1146
+ *
1147
+ * The seed is computed from two synchronous reads: the settings row's
1148
+ * `read`/`write`/`defaultGroups` (seeds for a session that has none of its own)
1149
+ * and, for a subagent child, the parent's pair AS IT STANDS NOW. A parent with
1150
+ * no record of its own yields `undefined` from {@link inheritedAxes}, which is
1151
+ * the same answer `pin` gets, so parent and child agree in that case too.
1152
+ * @param ctx - host context; carries the settings service the seed is read from.
1153
+ * @param session - the session to seed.
1154
+ * @param fallback - the composing plugin's own config, used only when no settings
1155
+ * service is mounted (where the namespace cannot be written anyway).
1156
+ * @returns the pair a newly created session would have been pinned with.
1157
+ */
1158
+ function seedAxesFor(ctx, session, fallback) {
1159
+ const section = declaredSection(ctx);
1160
+ const inherited = inheritedAxes(ctx, session);
1161
+ if (section === void 0) return seedGroupReferences(inherited ?? fallback?.axes ?? DEFAULT_AXES, fallback?.groups ?? []);
1162
+ return seedPair(section, inherited);
1163
+ }
1164
+ /**
1165
+ * The pure core of {@link seedAxesFor}: one settings row value, plus the pair a
1166
+ * subagent child inherits, into the pair a session with no record is seeded with.
1167
+ *
1168
+ * It is TOTAL on purpose, and the client half mirrors it step for step
1169
+ * (`sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`, pinned by
1170
+ * `tests/seed-parity.spec.ts`): an unreadable row, and an unreadable inherited
1171
+ * pair, answer {@link DEFAULT_AXES}. That is also what a live read answers — see
1172
+ * {@link SessionAxesStore.ensure} — so the pair a picker shows for a session with
1173
+ * no record and the pair the host enforces are one value rather than two.
1174
+ * @param row - the settings row's value, untrusted: a human may have edited it.
1175
+ * @param inherited - the parent's pair for a subagent child, or `undefined`.
1176
+ * @returns the seed pair.
1177
+ */
1178
+ function seedPair(row, inherited) {
1179
+ try {
1180
+ return seedGroupReferences(inherited === void 0 ? axesOf(row) : pairOf(inherited), defaultGroupIds(row));
1181
+ } catch {
1182
+ return DEFAULT_AXES;
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Re-normalize a pair another reader produced.
1187
+ *
1188
+ * A pair this package stored is already normalized; a hand-edited document's is
1189
+ * not, and the inherited pair the client half reads comes straight off the wire.
1190
+ * Running both through the same validation is what makes the two halves agree on
1191
+ * the bytes, not merely on the kinds.
1192
+ * @param pair - the untrusted pair.
1193
+ * @returns the normalized pair.
1194
+ * @throws When either member is not a legal axis value.
1195
+ */
1196
+ function pairOf(pair) {
1197
+ return {
1198
+ read: normalizeScope("read", pair.read),
1199
+ write: normalizeScope("write", pair.write)
1200
+ };
1201
+ }
1202
+ /**
1203
+ * The pair a **subagent child session** inherits, or `undefined` when this is not
1204
+ * a child or its parent holds no record.
1205
+ *
1206
+ * A child does not read the settings page: 0.1.7 deliberately seeds a child with
1207
+ * its parent's explicit per-session override, and the parent's STORED pair is the
1208
+ * only copy of that override this package has. Returning `undefined` (rather than
1209
+ * inventing one) leaves the caller on the settings seed, which is what the child
1210
+ * would have received had the parent never overridden anything — and is exactly
1211
+ * the pair an un-recorded parent is itself held to.
1212
+ *
1213
+ * The record is looked up by the id the child's header names. The parent's own
1214
+ * Session object is NOT required: requiring it made the answer depend on whether
1215
+ * the parent happened to be resident in this process, and the client half — which
1216
+ * reads the same document by the same id — cannot observe residency, so the two
1217
+ * would disagree for a child whose parent is not materialized.
1218
+ * @param ctx - host context carrying the axis store.
1219
+ * @param session - the session whose parent to read.
1220
+ * @returns the parent's pair, or `undefined`.
1221
+ */
1222
+ function inheritedAxes(ctx, session) {
1223
+ if (session.header.origin !== "subagent") return void 0;
1224
+ const parentId = session.header.parentSession;
1225
+ if (parentId === void 0) return void 0;
1226
+ return sessionAxesStore(ctx).get(String(parentId));
1227
+ }
1228
+ /** How many times one write re-reads the revision after a conflict. */
1229
+ const AXES_WRITE_ATTEMPTS = 4;
1230
+ /** Milliseconds before attempt N is issued, N counted from zero. */
1231
+ const AXES_WRITE_BACKOFF_MS = [
1232
+ 0,
1233
+ 25,
1234
+ 75,
1235
+ 200
1236
+ ];
1237
+ /**
1238
+ * A whole-document write was refused because another writer moved the settings
1239
+ * document between this writer's read and its write.
1240
+ *
1241
+ * It is thrown, never swallowed: the axis a person just picked is not in force
1242
+ * unless it was stored, and reporting success for a lost write would leave the
1243
+ * fence enforcing a range the picker does not show.
1244
+ */
1245
+ var SessionAxesConflictError = class extends Error {
1246
+ /** Stable machine code for callers that map failures to their own taxonomy. */
1247
+ code = "DUAL_AXIS_AXES_CONFLICT";
1248
+ /** The namespace whose write was refused. */
1249
+ ns = SESSION_AXES_NAMESPACE;
1250
+ /** Attempts made before giving up. */
1251
+ attempts;
1252
+ /**
1253
+ * @param cause - the last conflict the settings service raised.
1254
+ * @param attempts - attempts made before giving up.
1255
+ */
1256
+ constructor(cause, attempts) {
1257
+ super("dual-axis: the settings document changed under this writer " + String(attempts) + " times while storing a session axis pair; nothing was written", { cause });
1258
+ this.name = "SessionAxesConflictError";
1259
+ this.attempts = attempts;
1260
+ }
1261
+ };
1262
+ /**
1263
+ * Whether two narrowing reports say the same thing, so a republish that changes
1264
+ * nothing does not write the document.
1265
+ * @param left - one report, absent when nothing was ever published.
1266
+ * @param right - the other, absent under the same condition.
1267
+ * @returns whether both report the same removal.
1268
+ */
1269
+ function sameNarrowing(left, right) {
1270
+ if (left === void 0 || right === void 0) return false;
1271
+ return left.narrowed === right.narrowed && left.lostUnbounded === right.lostUnbounded && left.droppedRoots.length === right.droppedRoots.length && left.droppedRoots.every((root) => right.droppedRoots.includes(root));
1272
+ }
1273
+ /**
1274
+ * Whether a value is a plain data object.
1275
+ * @param value - the candidate.
1276
+ * @returns whether it is an object that is not null and not an array.
1277
+ */
1278
+ function isRecord(value) {
1279
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1280
+ }
1281
+ /**
1282
+ * Read one stored record into an axis pair, or throw naming the member that is
1283
+ * unreadable.
1284
+ * @param id - the session id the record belongs to, for the error message.
1285
+ * @param value - the untrusted record.
1286
+ * @returns the validated pair.
1287
+ * @throws When either member is not a legal axis value.
1288
+ */
1289
+ function parseSessionAxesRecord(id, value) {
1290
+ if (!isRecord(value)) throw new Error("dual-axis: the stored axes of session " + JSON.stringify(id) + " must be an object");
1291
+ const narrowing = parseNarrowing(value.narrowing);
1292
+ return {
1293
+ read: normalizeScope("read", value.read ?? DEFAULT_READ_SCOPE),
1294
+ write: normalizeScope("write", value.write ?? DEFAULT_WRITE_SCOPE),
1295
+ ...narrowing.narrowed ? { narrowing } : {}
1296
+ };
1297
+ }
1298
+ /**
1299
+ * Read the whole stored field. A human may have edited the settings document, so
1300
+ * an unreadable entry throws instead of being dropped: dropping it would silently
1301
+ * widen that session back to the deployment defaults.
1302
+ * @param value - the untrusted `axes` field.
1303
+ * @returns every stored pair, by session id.
1304
+ * @throws When the field is not an object of readable records.
1305
+ */
1306
+ function parseSessionAxesField(value) {
1307
+ if (value === void 0 || value === null) return {};
1308
+ if (!isRecord(value)) throw new Error("dual-axis: the stored session axes field must be an object");
1309
+ const parsed = {};
1310
+ for (const [id, record] of Object.entries(value)) parsed[id] = parseSessionAxesRecord(id, record);
1311
+ return parsed;
1312
+ }
1313
+ /**
1314
+ * The stored pair as this package spells it everywhere else. The stored record is
1315
+ * already a pair; this exists so the store's return type is the shared one and a
1316
+ * caller cannot start depending on the record's identity.
1317
+ * @param record - the stored pair.
1318
+ * @returns the same pair.
1319
+ */
1320
+ function scopesOf(record) {
1321
+ return {
1322
+ read: record.read,
1323
+ write: record.write
1324
+ };
1325
+ }
1326
+ /**
1327
+ * The read and write face of {@link SESSION_AXES_NAMESPACE}, plus the sweep that
1328
+ * keeps it from growing with the sessions that no longer exist.
1329
+ *
1330
+ * Reads go through `settings.describe()`, the same projection the settings page
1331
+ * renders from, and are memoized on the namespace's `revision` — which 0.1.7 bumps
1332
+ * exactly when that entry's stored profile override changes
1333
+ * (`packages/settings/settings/src/index.ts:311-317`). A decision path therefore
1334
+ * pays one `describe()` per document change, not one per tool dispatch.
1335
+ */
1336
+ var SessionAxesStore = class {
1337
+ ctx;
1338
+ options;
1339
+ cachedRevision;
1340
+ cachedRecords = {};
1341
+ /** Parsed records by the JSON spelling that produced them. */
1342
+ bySpelling = /* @__PURE__ */ new Map();
1343
+ /**
1344
+ * Session ids with a repair write outstanding (or already landed) since the
1345
+ * record was found missing. Prevents one repair per tool dispatch while the
1346
+ * whole-document write is in flight; cleared when a write FAILS so the next
1347
+ * touch retries instead of leaving the session on the defaults forever.
1348
+ */
1349
+ repairing = /* @__PURE__ */ new Set();
1350
+ /**
1351
+ * The last narrowing report this store handed to a write, by session id. A
1352
+ * decision path re-resolves on every touch, so this is what keeps it from
1353
+ * queueing the same document write over and over; it is cleared when a write
1354
+ * lands (or fails), never on a read.
1355
+ */
1356
+ narrowingMemo = /* @__PURE__ */ new Map();
1357
+ /** Sessions with a narrowing write outstanding right now. */
1358
+ narrowingWriting = /* @__PURE__ */ new Set();
1359
+ /**
1360
+ * @param ctx - host context; the settings service is looked up, never injected,
1361
+ * so a composition without settings still loads this package's fence.
1362
+ * @param options - retry policy and the sleep used between attempts.
1363
+ */
1364
+ constructor(ctx, options = {}) {
1365
+ this.ctx = ctx;
1366
+ this.options = {
1367
+ attempts: options.attempts ?? 4,
1368
+ backoffMs: options.backoffMs ?? AXES_WRITE_BACKOFF_MS,
1369
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => {
1370
+ setTimeout(resolve, ms);
1371
+ }))
1372
+ };
1373
+ }
1374
+ /**
1375
+ * This namespace's current settings row, or `undefined` when no settings
1376
+ * service is mounted or the entry is not composed.
1377
+ * @returns the descriptor carrying the value and the revision.
1378
+ */
1379
+ descriptor() {
1380
+ const settings = this.ctx.get("settings");
1381
+ if (settings === void 0) return void 0;
1382
+ return settings.describe().find((row) => row.ns === SESSION_AXES_NAMESPACE);
1383
+ }
1384
+ /**
1385
+ * Every stored pair, by session id.
1386
+ * @returns the parsed field.
1387
+ * @throws When the document carries an unreadable record.
1388
+ */
1389
+ records() {
1390
+ const descriptor = this.descriptor();
1391
+ if (descriptor === void 0) return {};
1392
+ if (descriptor.revision === this.cachedRevision) return this.cachedRecords;
1393
+ const value = isRecord(descriptor.value) ? descriptor.value[SESSION_AXES_FIELD] : void 0;
1394
+ const spelling = JSON.stringify(value ?? null);
1395
+ let records = this.bySpelling.get(spelling);
1396
+ if (records === void 0) {
1397
+ records = parseSessionAxesField(value);
1398
+ this.bySpelling.set(spelling, records);
1399
+ }
1400
+ this.cachedRevision = descriptor.revision;
1401
+ this.cachedRecords = records;
1402
+ return records;
1403
+ }
1404
+ /**
1405
+ * One session's stored pair.
1406
+ * @param sessionId - the session whose axes to read.
1407
+ * @returns the pair, or `undefined` when this session has no record yet.
1408
+ */
1409
+ get(sessionId) {
1410
+ const record = this.records()[sessionId];
1411
+ return record === void 0 ? void 0 : scopesOf(record);
1412
+ }
1413
+ /**
1414
+ * One session's stored pair, or the built-in defaults.
1415
+ *
1416
+ * The answer for a caller that has no session to build a seed from, and the only
1417
+ * remaining reader of {@link DEFAULT_AXES}. Every live path reads {@link ensure}
1418
+ * instead, whose un-recorded answer is the session's own seed.
1419
+ * @param sessionId - the session whose axes to read.
1420
+ * @returns the pair in force.
1421
+ */
1422
+ getOr(sessionId) {
1423
+ return this.get(sessionId) ?? DEFAULT_AXES;
1424
+ }
1425
+ /**
1426
+ * One session's pair, with an un-recorded session scheduled for repair.
1427
+ *
1428
+ * This is the read every LIVE touch point uses (the model-facing prompt, the
1429
+ * read fence, and `/axis`). A session that has a record answers it; a session
1430
+ * that has none answers {@link seedAxesFor} — the pair `pin` writes for a newly
1431
+ * created session — and that ONE value is both this read's answer and what the
1432
+ * scheduled repair stores. The answer and the write therefore cannot disagree,
1433
+ * and a session created under a saved default runs under that default from its
1434
+ * FIRST turn instead of waiting for the record to land. The record is what
1435
+ * answers from the moment it lands.
1436
+ *
1437
+ * An un-recorded session is only REPAIRED when it has been used
1438
+ * ({@link hasContent}). Until then the seed is recomputed on every read, so the
1439
+ * settings row stays live: a session the workspace picker reopens before anyone
1440
+ * has sent anything follows the settings page, which is the whole point of not
1441
+ * having a record yet. The moment a turn lands, the next read repairs the
1442
+ * session and it freezes — the same seed it answered with, computed from the row
1443
+ * as it then stood.
1444
+ *
1445
+ * The seed is a thunk because only the caller can build it (it needs the
1446
+ * session, and the composing plugin's config for a settings-less host). It is
1447
+ * evaluated exactly once per un-recorded read — those reads are a prompt
1448
+ * assembly, a tool dispatch and a pick, never a hot loop — and it must have no
1449
+ * side effect, because its value IS this read's answer.
1450
+ * @param sessionId - the session whose axes to read.
1451
+ * @param seed - builds the pair a repair persists AND this read answers; never
1452
+ * called when a record exists.
1453
+ * @param frozen - whether the session has been used, so its pair must be
1454
+ * written rather than recomputed. Defaults to `true`: a caller that cannot
1455
+ * judge the session's content keeps the pre-existing freeze-on-first-touch
1456
+ * behaviour, which is the safe direction — it never widens a live session's
1457
+ * axes behind the person's back.
1458
+ * @returns the pair in force right now.
1459
+ */
1460
+ ensure(sessionId, seed, frozen = true) {
1461
+ const standing = this.get(sessionId);
1462
+ if (standing !== void 0) return standing;
1463
+ let seeded;
1464
+ try {
1465
+ seeded = seed();
1466
+ } catch {
1467
+ if (frozen) this.scheduleRepair(sessionId, seed);
1468
+ return DEFAULT_AXES;
1469
+ }
1470
+ if (frozen) this.scheduleRepair(sessionId, () => seeded);
1471
+ return seeded;
1472
+ }
1473
+ /**
1474
+ * Publish the narrowing a live read just resolved, when the record does not
1475
+ * already carry it.
1476
+ *
1477
+ * This is how the client half learns what the read axis removed from the write
1478
+ * range without re-implementing the path algebra: it reads this member out of
1479
+ * the same document its two dropdowns already read. The value comes from the
1480
+ * very resolution the prompt and the fence used, so the surfaced narrowing and
1481
+ * the enforced one are one computation rather than two that must agree.
1482
+ *
1483
+ * Called on every touch of a session that references groups, so an edit to a
1484
+ * group's rules is republished by the next touch instead of waiting for the
1485
+ * record to be rewritten for another reason. A session with no record is left
1486
+ * alone: its axes are recomputed from the settings row on every read, and a
1487
+ * store of its own would freeze that. Writes are fire-and-forget — a caller is
1488
+ * a synchronous decision path and its answer must not depend on a document
1489
+ * write.
1490
+ * @param sessionId - the session whose narrowing to publish.
1491
+ * @param narrowing - the value {@link resolveEffectiveAxes} returned for it.
1492
+ */
1493
+ refreshNarrowing(sessionId, narrowing) {
1494
+ const record = this.records()[sessionId];
1495
+ if (record === void 0) return;
1496
+ const stored = record.narrowing;
1497
+ if (stored === void 0 && !narrowing.narrowed) return;
1498
+ if (sameNarrowing(stored, narrowing)) {
1499
+ this.narrowingMemo.delete(sessionId);
1500
+ return;
1501
+ }
1502
+ if (sameNarrowing(this.narrowingMemo.get(sessionId), narrowing)) return;
1503
+ this.narrowingMemo.set(sessionId, narrowing);
1504
+ this.writeNarrowing(sessionId, narrowing);
1505
+ }
1506
+ /**
1507
+ * Store one narrowing report for a session that already has a record, at most
1508
+ * once per value.
1509
+ *
1510
+ * A failed write only forgets what was attempted: the next touch republishes
1511
+ * the same report, and the dropdowns keep showing the last stored one instead
1512
+ * of nothing. It never touches the axes, so a refresh that loses its race
1513
+ * cannot revert a pick.
1514
+ * @param sessionId - the session to update.
1515
+ * @param narrowing - the report to store.
1516
+ */
1517
+ writeNarrowing(sessionId, narrowing) {
1518
+ if (this.narrowingWriting.has(sessionId)) return;
1519
+ this.narrowingWriting.add(sessionId);
1520
+ Promise.resolve().then(async () => {
1521
+ const record = this.records()[sessionId];
1522
+ if (record === void 0) return;
1523
+ const stored = record.narrowing;
1524
+ if (stored === void 0 && !narrowing.narrowed) return;
1525
+ if (sameNarrowing(stored, narrowing)) return;
1526
+ const { narrowing: _retired, ...axes } = record;
1527
+ await this.replaceRecords({
1528
+ ...this.records(),
1529
+ [sessionId]: {
1530
+ ...axes,
1531
+ ...narrowing.narrowed ? { narrowing } : {}
1532
+ }
1533
+ });
1534
+ }).catch((error) => {
1535
+ this.ctx.logger?.warn("dual-axis: could not store the narrowing of session \"%s\"; the dropdowns keep the last stored report", sessionId);
1536
+ this.ctx.logger?.warn(error);
1537
+ }).finally(() => {
1538
+ this.narrowingWriting.delete(sessionId);
1539
+ this.narrowingMemo.delete(sessionId);
1540
+ });
1541
+ }
1542
+ /**
1543
+ * Store one session's pair, whole-document, and report a lost write loudly.
1544
+ *
1545
+ * A single `replace` of the field: removal needs the whole map anyway, and a
1546
+ * per-id path write cannot prune. Every attempt re-reads the revision, so an
1547
+ * edit that landed between the read and the write is retried against the value
1548
+ * it produced instead of being overwritten from a stale snapshot.
1549
+ * @param sessionId - the session the pair belongs to.
1550
+ * @param axes - the pair to store.
1551
+ * @throws {SessionAxesConflictError} when every attempt lost the revision race.
1552
+ * @throws When no settings service serves this namespace.
1553
+ */
1554
+ async set(sessionId, axes, narrowing) {
1555
+ await this.replaceRecords({
1556
+ ...this.records(),
1557
+ [sessionId]: {
1558
+ read: axes.read,
1559
+ write: axes.write,
1560
+ ...narrowing === void 0 || !narrowing.narrowed ? {} : { narrowing }
1561
+ }
1562
+ });
1563
+ if (narrowing !== void 0) this.narrowingMemo.set(sessionId, narrowing);
1564
+ }
1565
+ /**
1566
+ * Persist one un-recorded session's seed, at most once until it lands.
1567
+ *
1568
+ * Fire-and-forget on purpose: every caller is a synchronous decision path
1569
+ * (a tool dispatch, a prompt assembly) that cannot await a whole-document
1570
+ * write, and none of them may fail because the repair could not be stored —
1571
+ * the answer that read already gave stands either way. A failed repair is
1572
+ * logged and re-armed.
1573
+ * @param sessionId - the session to repair.
1574
+ * @param seed - builds the pair to persist; may throw on an unreadable document.
1575
+ */
1576
+ scheduleRepair(sessionId, seed) {
1577
+ if (this.repairing.has(sessionId)) return;
1578
+ this.repairing.add(sessionId);
1579
+ Promise.resolve().then(async () => {
1580
+ if (this.get(sessionId) !== void 0) return;
1581
+ await this.set(sessionId, seed());
1582
+ this.repairing.delete(sessionId);
1583
+ }).catch((error) => {
1584
+ this.repairing.delete(sessionId);
1585
+ this.ctx.logger?.warn("dual-axis: could not store the axes of session \"%s\"; it stays on the default pair", sessionId);
1586
+ this.ctx.logger?.warn(error);
1587
+ });
1588
+ }
1589
+ /**
1590
+ * Prune records whose session no longer exists.
1591
+ *
1592
+ * The deletion signal is the persistence layer itself: a session whose id is
1593
+ * absent from `sessionPersistence.list()` has no stored log. `session/disposed`
1594
+ * is NOT that signal — residency churn disposes a session whose file is still on
1595
+ * disk, and dropping its axes there would silently reset a session that is about
1596
+ * to be resumed.
1597
+ * @returns the ids dropped, or `undefined` when the sweep cannot judge (no
1598
+ * persistence service mounted, or a listing failed): an unjudgeable sweep
1599
+ * removes nothing.
1600
+ */
1601
+ async sweep() {
1602
+ const records = this.records();
1603
+ const ids = Object.keys(records);
1604
+ if (ids.length === 0) return {
1605
+ removed: [],
1606
+ before: 0
1607
+ };
1608
+ const persistence = this.ctx.get("sessionPersistence");
1609
+ if (persistence === void 0) return void 0;
1610
+ let live;
1611
+ try {
1612
+ const listed = await persistence.list();
1613
+ live = new Set(listed.map((snapshot) => String(snapshot.header.id)));
1614
+ } catch (error) {
1615
+ this.ctx.logger?.warn("dual-axis: could not list stored sessions, keeping every axis record");
1616
+ this.ctx.logger?.warn(error);
1617
+ return;
1618
+ }
1619
+ const kept = {};
1620
+ const removed = [];
1621
+ for (const id of ids) {
1622
+ const record = records[id];
1623
+ if (record === void 0) continue;
1624
+ if (live.has(id)) kept[id] = record;
1625
+ else removed.push(id);
1626
+ }
1627
+ if (removed.length === 0) return {
1628
+ removed: [],
1629
+ before: ids.length
1630
+ };
1631
+ await this.replaceRecords(kept);
1632
+ return {
1633
+ removed,
1634
+ before: ids.length
1635
+ };
1636
+ }
1637
+ /**
1638
+ * Replace the whole field, retrying while the revision fence refuses it.
1639
+ * @param records - the complete next field.
1640
+ * @throws {SessionAxesConflictError} when every attempt was refused.
1641
+ */
1642
+ async replaceRecords(records) {
1643
+ const settings = this.ctx.get("settings");
1644
+ const entryExists = this.descriptor() !== void 0;
1645
+ if (settings === void 0 || !entryExists) throw new Error("dual-axis: no configurable plugin entry \"dual-axis-sessions\"; the session axis store cannot be written");
1646
+ let conflict;
1647
+ for (let attempt = 0; attempt < this.options.attempts; attempt += 1) {
1648
+ const backoff = this.options.backoffMs[attempt] ?? this.options.backoffMs[this.options.backoffMs.length - 1] ?? 0;
1649
+ if (backoff > 0) await this.options.sleep(backoff);
1650
+ const revision = this.descriptor()?.revision;
1651
+ try {
1652
+ await settings.replace(SESSION_AXES_NAMESPACE, { [SESSION_AXES_FIELD]: records }, revision);
1653
+ return;
1654
+ } catch (error) {
1655
+ conflict = error;
1656
+ }
1657
+ }
1658
+ throw new SessionAxesConflictError(conflict, this.options.attempts);
1659
+ }
1660
+ };
1661
+ /** Stores by host context, so every consumer of one process shares one cache. */
1662
+ const stores = /* @__PURE__ */ new WeakMap();
1663
+ /**
1664
+ * The store of one host context. Consumers hold no service of their own: the
1665
+ * fence and the prompt must keep working in a composition that never mounted the
1666
+ * settings service, where the store simply has nothing to read.
1667
+ * @param ctx - host context.
1668
+ * @returns the shared store.
1669
+ */
1670
+ function sessionAxesStore(ctx) {
1671
+ const existing = stores.get(ctx);
1672
+ if (existing !== void 0) return existing;
1673
+ const created = new SessionAxesStore(ctx);
1674
+ stores.set(ctx, created);
1675
+ return created;
1676
+ }
1677
+ /**
1678
+ * This entry's plugin body.
1679
+ *
1680
+ * The entry exists for its Config: `SettingsForms.describe` projects the schemas
1681
+ * of LOADED entries, so the session axis store has no namespace to live in until
1682
+ * a Loader row activates with this plugin. The body itself does nothing — every
1683
+ * read and write goes through {@link SessionAxesStore}, which the composing
1684
+ * plugin owns.
1685
+ * @returns nothing.
1686
+ */
1687
+ async function apply() {}
1688
+ Object.assign(apply, { Config: Config$1 });
1689
+ Object.defineProperty(apply, "name", {
1690
+ value: "dual-axis-sessions",
1691
+ configurable: true
1692
+ });
1693
+ //#endregion
1694
+ //#region lib/types/axis-entry.js
1695
+ /**
1696
+ * The dual-axis command's argument grammar: one client-composed entry string
1697
+ * into one access axis.
1698
+ *
1699
+ * The grammar is owned by the browser half's pickers, which emit exactly two
1700
+ * forms — <axis>:<deny|workspace|all> for the three closed values, and
1701
+ * <axis>:custom:base=<deny|workspace|all>[,groups=<id>|<id>][,allow=<absolute path>][,deny=<absolute path>]
1702
+ * from the path editor, where a repeated key carries several entries.
1703
+ *
1704
+ * A `groups` entry records the IDS ONLY, checked against the ids the settings
1705
+ * page currently defines and then stored verbatim. The group's rules are never
1706
+ * expanded into the entry: expansion happens at the moment of use, so editing a
1707
+ * group reaches every session that references it without a restart.
1708
+ *
1709
+ * The caller supplies the session's standing pair so a one-axis entry returns
1710
+ * the complete new pair; the axis the entry does not name keeps its value.
1711
+ *
1712
+ * @module @t4r71/dsh-dual-axis/axis-entry
1713
+ */
1714
+ /** The two axis names an entry string may start with. */
1715
+ const AXIS_ENTRY_NAMES = ["read", "write"];
1716
+ /**
1717
+ * Whether a spelling names an access axis.
1718
+ * @param value - the text before the first colon.
1719
+ * @returns whether it is read or write.
1720
+ */
1721
+ function isAxisName(value) {
1722
+ return AXIS_ENTRY_NAMES.includes(value);
1723
+ }
1724
+ /**
1725
+ * Whether a spelling names a custom base.
1726
+ * @param value - the candidate base.
1727
+ * @returns whether it is one of the three bases.
1728
+ */
1729
+ function isAxisBase(value) {
1730
+ return value === "deny" || value === "workspace" || value === "all";
1731
+ }
1732
+ /**
1733
+ * Parse the base=…,groups=…,allow=…,deny=… fragment that follows <axis>:custom:.
1734
+ * @param fragment - everything after the second colon.
1735
+ * @param knownGroups - the rule-group ids the settings page currently defines.
1736
+ * @returns the custom scope, or the sentence explaining which entry failed.
1737
+ */
1738
+ function parseCustomFragment(fragment, knownGroups) {
1739
+ const entries = fragment.split(",");
1740
+ const head = entries[0];
1741
+ if (head === void 0 || !head.startsWith("base=")) return {
1742
+ ok: false,
1743
+ problem: "a custom axis must begin with base=<deny|workspace|all>"
1744
+ };
1745
+ const base = head.slice(5);
1746
+ if (!isAxisBase(base)) return {
1747
+ ok: false,
1748
+ problem: "\"" + base + "\" is not a custom base; expected deny, workspace, or all"
1749
+ };
1750
+ const groups = [];
1751
+ const allow = [];
1752
+ const deny = [];
1753
+ for (let index = 1; index < entries.length; index += 1) {
1754
+ const entry = entries[index];
1755
+ if (entry === void 0) continue;
1756
+ const separator = entry.indexOf("=");
1757
+ if (separator < 0) return {
1758
+ ok: false,
1759
+ problem: "\"" + entry + "\" is not a key=value entry; expected groups=<id>|<id>, allow=<absolute path>, or deny=<absolute path>"
1760
+ };
1761
+ const key = entry.slice(0, separator);
1762
+ const value = entry.slice(separator + 1);
1763
+ if (key !== "groups" && key !== "allow" && key !== "deny") return {
1764
+ ok: false,
1765
+ problem: "\"" + key + "\" is not a custom key; expected groups, allow, or deny"
1766
+ };
1767
+ if (key === "groups") {
1768
+ for (const id of value.split("|")) {
1769
+ if (id.length === 0) return {
1770
+ ok: false,
1771
+ problem: "groups entry " + JSON.stringify(value) + " carries an empty rule-group id"
1772
+ };
1773
+ if (!knownGroups.has(id)) return {
1774
+ ok: false,
1775
+ problem: "\"" + id + "\" is not a rule group the settings page defines"
1776
+ };
1777
+ if (!groups.includes(id)) groups.push(id);
1778
+ }
1779
+ continue;
1780
+ }
1781
+ if (!isAbsoluteSpelling(value)) return {
1782
+ ok: false,
1783
+ problem: key + " entry " + JSON.stringify(value) + " must be a non-empty absolute path"
1784
+ };
1785
+ if (key === "allow") allow.push(value);
1786
+ else deny.push(value);
1787
+ }
1788
+ return {
1789
+ ok: true,
1790
+ scope: {
1791
+ kind: "custom",
1792
+ base,
1793
+ groups: Object.freeze(groups),
1794
+ allow: Object.freeze(allow),
1795
+ deny: Object.freeze(deny)
1796
+ }
1797
+ };
1798
+ }
1799
+ /**
1800
+ * Read one client-composed axis entry into the complete axis pair it selects.
1801
+ * @param rawInput - the command invocation's verbatim input; the commands
1802
+ * registry passes the text after the command name including the separating
1803
+ * space, so this trims first.
1804
+ * @param current - the session's standing pair; the axis the entry does not
1805
+ * name keeps its value from here.
1806
+ * @param knownGroups - the rule-group ids the settings page currently defines;
1807
+ * a `groups` entry naming anything else is refused.
1808
+ * @returns the new pair, or the sentence explaining why the entry was refused.
1809
+ */
1810
+ function parseAxisEntry(rawInput, current, knownGroups = /* @__PURE__ */ new Set()) {
1811
+ const input = rawInput.trim();
1812
+ if (input === "") return {
1813
+ ok: false,
1814
+ problem: "an axis switch requires <read|write>:<deny|workspace|all|custom:base=…,groups=…,allow=…,deny=…>"
1815
+ };
1816
+ const headEnd = input.indexOf(":");
1817
+ if (headEnd < 0) return {
1818
+ ok: false,
1819
+ problem: "\"" + input + "\" names no axis; expected <read|write>:<value>"
1820
+ };
1821
+ const axisName = input.slice(0, headEnd);
1822
+ if (!isAxisName(axisName)) return {
1823
+ ok: false,
1824
+ problem: "\"" + axisName + "\" is not an axis; expected read or write"
1825
+ };
1826
+ const tail = input.slice(headEnd + 1);
1827
+ const kindEnd = tail.indexOf(":");
1828
+ const kind = kindEnd < 0 ? tail : tail.slice(0, kindEnd);
1829
+ const rest = kindEnd < 0 ? "" : tail.slice(kindEnd + 1);
1830
+ let scope;
1831
+ if (kind === "custom") {
1832
+ if (rest === "") return {
1833
+ ok: false,
1834
+ problem: "custom needs its base and path lists: custom:base=<deny|workspace|all>[,groups=<id>|<id>][,allow=<absolute path>][,deny=<absolute path>]"
1835
+ };
1836
+ const parsed = parseCustomFragment(rest, knownGroups);
1837
+ if (!parsed.ok) return parsed;
1838
+ scope = parsed.scope;
1839
+ } else if (isAxisBase(kind)) {
1840
+ if (rest !== "") return {
1841
+ ok: false,
1842
+ problem: "\"" + kind + "\" takes no further arguments"
1843
+ };
1844
+ scope = { kind };
1845
+ } else return {
1846
+ ok: false,
1847
+ problem: "\"" + kind + "\" is not an axis value; expected deny, workspace, all, or custom"
1848
+ };
1849
+ return {
1850
+ ok: true,
1851
+ axes: axisName === "read" ? {
1852
+ read: scope,
1853
+ write: current.write
1854
+ } : {
1855
+ read: current.read,
1856
+ write: scope
1857
+ }
1858
+ };
1859
+ }
1860
+ //#endregion
1861
+ //#region lib/types/axis-command.js
1862
+ /**
1863
+ * The dual-axis bundle's command registration: the one write path the two
1864
+ * composer dropdowns reach the host through.
1865
+ *
1866
+ * The name is NOT permission. @deepseek-ai/dsh-permission-presets registers
1867
+ * /permission on the global command layer, and the registry's NamedEntries
1868
+ * throws on a duplicate global name, so reusing it would fail this plugin's
1869
+ * load. The preset picker keeps /permission; this command owns the axes.
1870
+ *
1871
+ * Registration rides ctx.inject(['commands'], …) rather than the plugin's
1872
+ * static inject: a profile without a command registry composes this bundle
1873
+ * for its fence and prompt, and an unconditional ctx.commands access would fail
1874
+ * that load.
1875
+ *
1876
+ * The write is a WHOLE-DOCUMENT settings write and therefore asynchronous, so
1877
+ * the handler is too. A lost revision race settles as an ordinary command
1878
+ * error carrying the conflict's own sentence: the dispatching surface renders
1879
+ * it beside the picker, and nothing is reported as applied that was not stored.
1880
+ *
1881
+ * @module @t4r71/dsh-dual-axis/axis-command
1882
+ */
1883
+ /** The registered command name, without its leading slash. */
1884
+ const AXIS_COMMAND_NAME = "axis";
1885
+ /**
1886
+ * Render one axis value for the settlement sentence.
1887
+ * @param scope - the axis value.
1888
+ * @returns its kind, with a custom value's base and list sizes.
1889
+ */
1890
+ function describeScope(scope) {
1891
+ if (scope.kind !== "custom") return scope.kind;
1892
+ return "custom(base=" + scope.base + ", +" + String(scope.allow.length) + ", -" + String(scope.deny.length) + ")";
1893
+ }
1894
+ /**
1895
+ * Register the axis command for every composed command adapter.
1896
+ * @param ctx - host context; the registration is owned by this context's fiber.
1897
+ * @param options - the standing-pair reader, the group-id reader, and the write path.
1898
+ */
1899
+ function registerAxisCommand(ctx, options) {
1900
+ ctx.inject(["commands"], (commandCtx) => {
1901
+ commandCtx.commands.register({
1902
+ definitionId: CommandDefinitionId("@t4r71/dsh-dual-axis"),
1903
+ name: AXIS_COMMAND_NAME,
1904
+ description: "Switch one file-sandbox access axis (read or write)",
1905
+ input: { hint: "<read|write>:<deny|workspace|all|custom:base=…,groups=…,allow=…,deny=…>" },
1906
+ handler: async ({ agent, rawInput }) => {
1907
+ const parsed = parseAxisEntry(rawInput, options.current(agent.session), options.knownGroups());
1908
+ if (!parsed.ok) return {
1909
+ kind: "error",
1910
+ text: parsed.problem + ". Usage: /axis <read|write>:<deny|workspace|all|custom:base=…,groups=…,allow=…,deny=…>"
1911
+ };
1912
+ try {
1913
+ await options.write(agent.session, parsed.axes);
1914
+ } catch (error) {
1915
+ return {
1916
+ kind: "error",
1917
+ text: (error instanceof SessionAxesConflictError ? error.message : "the axis was not stored: " + (error instanceof Error ? error.message : String(error))) + ". Usage: /axis <read|write>:<deny|workspace|all|custom:base=…,groups=…,allow=…,deny=…>"
1918
+ };
1919
+ }
1920
+ return {
1921
+ kind: "success",
1922
+ text: "read " + describeScope(parsed.axes.read) + " | write " + describeScope(parsed.axes.write)
1923
+ };
1924
+ }
1925
+ });
1926
+ });
1927
+ }
1928
+ //#endregion
1929
+ //#region lib/types/scope-prompt.js
1930
+ /**
1931
+ * The model-facing text of the two access axes: ONE source for both the
1932
+ * runtime-context paragraph the model reads before it acts and the refusal a
1933
+ * fence returns when it acts anyway.
1934
+ *
1935
+ * Why one module: the paragraph and the refusal must state the same range. Two
1936
+ * hand-written copies drift, and the drift is invisible — the model would plan
1937
+ * against one boundary in its context and be denied by a different one. Both
1938
+ * callers therefore call {@link renderAxisRange} for the range sentence and
1939
+ * share {@link NO_BYPASS} and {@link WIDENING_EXIT} verbatim; neither string is
1940
+ * spelled anywhere else in this package.
1941
+ *
1942
+ * Both callers feed it only session-log facts plus the group definitions the
1943
+ * log's ids resolve against: the axis preset is the `dual-axis/scopes` fold (the
1944
+ * projection unit), the workspace root is the session header's `cwd`, and the
1945
+ * group library is the settings row's `groups` field. Nothing here reads ambient
1946
+ * state of its own, so the rendered text is reconstructable from the log and
1947
+ * that library.
1948
+ *
1949
+ * @module @t4r71/dsh-dual-axis/scope-prompt
1950
+ */
1951
+ /**
1952
+ * The sentence naming what is NOT a way around a refusal. Three facts, all of
1953
+ * which the model otherwise has to guess: the denial is the session's rule
1954
+ * rather than the calling tool's limitation; where each axis is enforced; and
1955
+ * that the two axes are layered differently.
1956
+ *
1957
+ * The layering is stated per axis because asserting it for the whole fence
1958
+ * would be false. `read-guard.ts` enforces the read axis and the allow/deny
1959
+ * path entries of the effective write range at the tool-dispatch layer, and the
1960
+ * write BASE tier a second time below it: `session-axes.ts` mirrors the
1961
+ * narrower of the two bases onto the session `sandbox/mode`, which the command
1962
+ * tools themselves run under. An effective write range based on `all` mirrors
1963
+ * to `danger-full-access`, so for that base there is no layer below the tool
1964
+ * layer at all.
1965
+ */
1966
+ const NO_BYPASS = "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.";
1967
+ /**
1968
+ * The sentence naming the only two compliant moves. Both are user-facing, so it
1969
+ * names where the user acts: the settings page row and the two composer
1970
+ * dropdowns are the two surfaces that write the same session axes.
1971
+ */
1972
+ const WIDENING_EXIT = "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.";
1973
+ /** Render one path list for the model; an empty list is stated, never left blank. */
1974
+ function renderRoots(roots) {
1975
+ return roots.length === 0 ? "(none)" : JSON.stringify(roots);
1976
+ }
1977
+ /**
1978
+ * Render one axis as the concrete places it permits and forbids, with the
1979
+ * configured entries already resolved to canonical absolute roots.
1980
+ *
1981
+ * A model plans against directories, not at mode names: `workspace` alone
1982
+ * leaves it guessing whether the platform temp areas count, and `custom`
1983
+ * leaves it guessing which paths were added or removed. {@link resolveScope}
1984
+ * answers both, so this prints its result instead of the axis value.
1985
+ *
1986
+ * A `custom` axis whose entries cannot be resolved (a foreign or hand-edited
1987
+ * log carrying a relative path, which `ScopeConfigError` refuses) is stated as
1988
+ * permitting nothing rather than throwing: the paragraph must not fail the
1989
+ * whole assembly, and a range that cannot be evaluated must never read as a
1990
+ * wider one.
1991
+ * @param axis - the axis value in force.
1992
+ * @param workspaceRoot - the session workspace root a `workspace` and `custom` axis resolve against.
1993
+ * @returns the range sentence, e.g. `kind custom; allowed roots: ["C:\\ws"]; denied roots: ["C:\\ws\\secret"] (a denied path always wins)`.
1994
+ */
1995
+ function renderAxisRange(axis, workspaceRoot) {
1996
+ let resolved;
1997
+ try {
1998
+ resolved = resolveScope(axis, { workspaceRoot });
1999
+ } catch (error) {
2000
+ const reason = error instanceof Error ? error.message : String(error);
2001
+ return `kind ${axis.kind}; the configured entries could not be resolved (${reason}), so no path is known to be allowed`;
2002
+ }
2003
+ const allowed = resolved.unbounded ? "every path on this host except the denied roots below" : renderRoots(resolved.allow);
2004
+ const denied = resolved.deny.length === 0 ? renderRoots(resolved.deny) : `${renderRoots(resolved.deny)} (a denied path always wins)`;
2005
+ return `kind ${axis.kind}; allowed roots: ${allowed}; denied roots: ${denied}`;
2006
+ }
2007
+ /**
2008
+ * The sentence reporting what the write-never-exceeds-read invariant removed.
2009
+ *
2010
+ * Stated because the removal is otherwise invisible: a rule group that grants
2011
+ * write access to a directory the read axis does not cover grants nothing, and a
2012
+ * user who cannot see that concludes the rule was ignored. Empty when the
2013
+ * intersection removed nothing, so a session that narrowed nothing is not
2014
+ * warned about a narrowing that did not happen.
2015
+ * @param narrowing - what the resolution removed from the write axis.
2016
+ * @returns the sentence, or the empty string when nothing was removed.
2017
+ */
2018
+ function renderNarrowing(narrowing) {
2019
+ if (!narrowing.narrowed) return "";
2020
+ const parts = [];
2021
+ if (narrowing.droppedRoots.length > 0) parts.push(`the read axis removed ${renderRoots(narrowing.droppedRoots)} from it`);
2022
+ if (narrowing.lostUnbounded) parts.push("it was unbounded, and the read range is now its entire bound");
2023
+ 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.";
2024
+ }
2025
+ /**
2026
+ * The runtime-context paragraph naming both axes as they stand for one session.
2027
+ *
2028
+ * The write member is the EFFECTIVE write range: the write axis with every
2029
+ * referenced group expanded, intersected with the read axis, and the intersection
2030
+ * is stated rather than left for the model to infer. A model that assumed the
2031
+ * write axis stood alone would plan writes the session refuses; one that assumed
2032
+ * the intersection without being told would refuse work the axes permit.
2033
+ * @param axes - the effective axis pair for the session.
2034
+ * @param workspaceRoot - the session's workspace root.
2035
+ * @returns the paragraph, ready to be contributed as one runtime-context entry.
2036
+ */
2037
+ function renderScopePrompt(axes, workspaceRoot) {
2038
+ const narrowing = renderNarrowing(axes.narrowing);
2039
+ return `Current DSH file access axes for this session: read — ${renderAxisRange(axes.read, workspaceRoot)}; write — ${renderAxisRange(axes.write, workspaceRoot)}. A read outside the read axis is refused, and a modification outside the write axis is refused. The write range above is the write axis INTERSECTED with the read axis: a write is never permitted outside the read range, so a directory the read axis does not cover stays unwritable even when the write axis or one of its rule groups names it. ` + (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.";
2040
+ }
2041
+ /**
2042
+ * The notice stating that a session's axis preset could not be resolved, so no
2043
+ * path is known to be allowed.
2044
+ *
2045
+ * Rendered both into the prompt and into a refusal, from this one source: an
2046
+ * unresolvable reference — a rule group deleted while a session still references
2047
+ * it — must fail closed everywhere it is met, and must say which id is missing
2048
+ * rather than behaving like a group that allows nothing in particular.
2049
+ * @param problem - the resolution failure, naming the offending id.
2050
+ * @returns the model-facing notice.
2051
+ */
2052
+ function unresolvedAxesNotice(problem) {
2053
+ 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.";
2054
+ }
2055
+ /**
2056
+ * The refusal a fence returns for one path, built from the same range sentence
2057
+ * the prompt uses.
2058
+ * @param input - the refused path, the axis that refused it, and the root it resolves against.
2059
+ * @returns the model-facing refusal text.
2060
+ */
2061
+ function scopeRefusal(input) {
2062
+ const narrowing = input.axis === "write" && input.narrowing !== void 0 ? renderNarrowing(input.narrowing) : "";
2063
+ 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.";
2064
+ }
2065
+ //#endregion
2066
+ //#region lib/types/containment.js
2067
+ /**
2068
+ * 目标路径包含判定:把上游 `@deepseek-ai/dsh-fs-sandbox/containment` 的
2069
+ * `isPathUnder` 原样内联到本包。
2070
+ *
2071
+ * 为什么必须内联:fs-sandbox@0.1.7-rc.2 的**发布包** exports 只开出
2072
+ * `.`、`./src/*` 与 `./package.json` 三条,**没有 `./containment`**;
2073
+ * 而源码工作区里该文件存在,靠路径解析能直接命中。于是同一个 import 在
2074
+ * 工作区里编译通过、装成 tgz 后运行期必然抛
2075
+ * `Package subpath './containment' is not defined by "exports"`,
2076
+ * 使 dual-axis 这个 entry 永远无法激活(fiberPhase 恒为 null)。
2077
+ * 自有副本消除这个 install-shape 依赖。
2078
+ * @module @t4r71/dsh-dual-axis/containment
2079
+ */
2080
+ const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
2081
+ function isMissing(error) {
2082
+ const code = error.code;
2083
+ return MISSING_CODES.has(code);
2084
+ }
2085
+ function comparablePath(path, caseSensitive) {
2086
+ return caseSensitive ? path : path.toLowerCase();
2087
+ }
2088
+ function isLexicallyUnder$1(path, root, caseSensitive) {
2089
+ const comparableTarget = comparablePath(path, caseSensitive);
2090
+ const comparableRoot = comparablePath(root, caseSensitive);
2091
+ if (comparableTarget === comparableRoot) return true;
2092
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
2093
+ return comparableTarget.startsWith(prefix);
2094
+ }
2095
+ async function statIfPresent(path) {
2096
+ try {
2097
+ return await stat(path, { bigint: true });
2098
+ } catch (error) {
2099
+ if (isMissing(error)) return void 0;
2100
+ throw error;
2101
+ }
2102
+ }
2103
+ function sameIdentity(left, right) {
2104
+ return left.dev === right.dev && left.ino === right.ino;
2105
+ }
2106
+ /**
2107
+ * Determine whether a canonical target is a writable root or lies beneath it.
2108
+ * The lexical fast path handles normal canonical spellings. When spellings
2109
+ * differ, walk the target's existing ancestors and compare filesystem identity
2110
+ * with the root; this recognizes Windows long-name/8.3 aliases and casing
2111
+ * without weakening containment to a textual approximation.
2112
+ * @param path - canonical target key, which may end in a missing suffix.
2113
+ * @param root - canonical writable root.
2114
+ * @param caseSensitive - whether lexical comparison preserves case; defaults
2115
+ * to the host filesystem convention used by supported platforms.
2116
+ * @returns whether the target is the root or a descendant of it.
2117
+ */
2118
+ async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
2119
+ if (isLexicallyUnder$1(path, root, caseSensitive)) return true;
2120
+ const rootInfo = await statIfPresent(root);
2121
+ if (!rootInfo) return false;
2122
+ let ancestor = path;
2123
+ while (true) {
2124
+ const ancestorInfo = await statIfPresent(ancestor);
2125
+ if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true;
2126
+ const parent = dirname(ancestor);
2127
+ if (parent === ancestor) return false;
2128
+ ancestor = parent;
2129
+ }
2130
+ }
2131
+ //#endregion
2132
+ //#region lib/types/fs-fence.js
2133
+ /**
2134
+ * Decide whether one read axis permits one canonical target key. This is the
2135
+ * fence's entire decision, as a pure function: it takes the evaluated range and
2136
+ * the containment predicate, so a test can exercise every branch with no
2137
+ * filesystem, no session, and no service.
2138
+ *
2139
+ * The error message names the READ axis's kind, never the mode that would
2140
+ * spell it: a read axis of `workspace` would otherwise be reported as
2141
+ * "workspace-write", a WRITE mode name this axis has no concept of.
2142
+ * @param read - the read axis in force.
2143
+ * @param policy - the workspace root a `workspace` read axis derives from.
2144
+ * @param targetKey - the target's canonical identity key.
2145
+ * @param contains - the containment predicate.
2146
+ * @returns `undefined` when permitted, or the refusal message.
2147
+ */
2148
+ function readAxisRefusal(read, policy, targetKey, contains) {
2149
+ if (scopeContains(resolveScope(read, policy), targetKey, contains)) return void 0;
2150
+ return refuse(read);
2151
+ }
2152
+ /**
2153
+ * The async twin of {@link readAxisRefusal}: production containment
2154
+ * (`isPathUnder`) walks the filesystem identity chain for alias-equivalent
2155
+ * roots, so the fence cannot use the lexical predicate alone.
2156
+ * @param read - the read axis in force.
2157
+ * @param policy - the workspace root a `workspace` read axis derives from.
2158
+ * @param targetKey - the target's canonical identity key.
2159
+ * @param contains - the async containment predicate.
2160
+ * @returns `undefined` when permitted, or the refusal message.
2161
+ */
2162
+ async function readAxisRefusalAsync(read, policy, targetKey, contains) {
2163
+ const scope = resolveScope(read, policy);
2164
+ for (const root of scope.deny) if (await contains(targetKey, root)) return refuse(read);
2165
+ if (scope.unbounded) return void 0;
2166
+ for (const root of scope.allow) if (await contains(targetKey, root)) return void 0;
2167
+ return refuse(read);
2168
+ }
2169
+ /**
2170
+ * The single refusal message both predicates return, naming the READ axis's
2171
+ * kind rather than the write mode that would spell it.
2172
+ * @param read - the refused read axis.
2173
+ * @returns the refusal message.
2174
+ */
2175
+ function refuse(read) {
2176
+ return `file access denied under the ${read.kind} read axis`;
2177
+ }
2178
+ /**
2179
+ * The read-axis-enforcing filesystem backend. Mount it in place of
2180
+ * `@deepseek-ai/dsh-fs-sandbox`: it registers the same `fs` service and
2181
+ * inherits that backend's write fence verbatim.
2182
+ */
2183
+ var DualAxisFileSystem = class extends SandboxedFileSystem {
2184
+ constructor(ctx, config) {
2185
+ super(ctx, config);
2186
+ }
2187
+ /**
2188
+ * The read axis pair of the session a read belongs to. Reads carry no
2189
+ * session, so the fence resolves the DEPLOYMENT default axes here; a caller
2190
+ * that knows the calling session passes them per call instead.
2191
+ * @returns the deployment default axis pair.
2192
+ */
2193
+ defaultAxes() {
2194
+ return effectiveScopes(this.ctx.sandboxPolicy.resolve());
2195
+ }
2196
+ /**
2197
+ * Fence the caller's read path against the READ axis, then return the target
2198
+ * for the inherited read. The check happens BEFORE any disk access.
2199
+ * @param target - the target the caller already resolved.
2200
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2201
+ * @returns the same target, once the read axis permits it.
2202
+ * @throws {FsError} `FS_SANDBOX_DENIED` when the read axis refuses the target.
2203
+ */
2204
+ async checkedRead(target, axes) {
2205
+ const policy = this.ctx.sandboxPolicy.resolve();
2206
+ const refusal = await readAxisRefusalAsync(axes?.read ?? this.defaultAxes().read, policy, target.targetKey, isPathUnder);
2207
+ if (refusal !== void 0) throw new FsError(`cannot read "${target.displayPath}": ${refusal}`, "FS_SANDBOX_DENIED");
2208
+ return target;
2209
+ }
2210
+ /**
2211
+ * Fence `lstat`'s string path by resolving the same target the inherited
2212
+ * implementation will inspect, then applying the read axis. The resolution
2213
+ * happens BEFORE the metadata read, so a refusal never touches the disk, and
2214
+ * the inherited call still receives the original arguments verbatim.
2215
+ * @param path - the requested path, relative to `opts.cwd` or the backend cwd.
2216
+ * @param opts - optional cwd for the resolution.
2217
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2218
+ * @returns the target the inherited `lstat` will inspect.
2219
+ */
2220
+ async checkedReadPath(path, opts, axes) {
2221
+ return this.checkedRead(await this.resolve(path, opts), axes);
2222
+ }
2223
+ /**
2224
+ * Fence the read by the per-call read axis, then delegate to the inherited
2225
+ * UTF-8 read.
2226
+ * @param target - the resolved target to read.
2227
+ * @param signal - aborts the read.
2228
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2229
+ * @returns the file content from the inherited backend.
2230
+ */
2231
+ async readText(target, signal, axes) {
2232
+ return super.readText(await this.checkedRead(target, axes), signal);
2233
+ }
2234
+ /**
2235
+ * Fence the read by the per-call read axis, then delegate to the inherited
2236
+ * streaming read. The fence runs BEFORE the iterable is handed out: a refused
2237
+ * stream never produces a chunk.
2238
+ * @param target - the resolved target to stream.
2239
+ * @param signal - aborts the read.
2240
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2241
+ * @returns the inherited chunk iterable.
2242
+ */
2243
+ async streamText(target, signal, axes) {
2244
+ return super.streamText(await this.checkedRead(target, axes), signal);
2245
+ }
2246
+ /**
2247
+ * Fence the read by the per-call read axis, then delegate to the inherited
2248
+ * bounded byte read.
2249
+ * @param target - the resolved target to read.
2250
+ * @param signal - aborts the read.
2251
+ * @param maxBytes - inclusive byte cap on the complete content.
2252
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2253
+ * @returns the file bytes from the inherited backend.
2254
+ */
2255
+ async readBytes(target, signal, maxBytes, axes) {
2256
+ return super.readBytes(await this.checkedRead(target, axes), signal, maxBytes);
2257
+ }
2258
+ /**
2259
+ * Fence the read by the per-call read axis, then delegate to the inherited
2260
+ * byte-window read.
2261
+ * @param target - the resolved target to read.
2262
+ * @param range - `offset` and `length` of the window.
2263
+ * @param signal - aborts the read.
2264
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2265
+ * @returns the byte window from the inherited backend.
2266
+ */
2267
+ async readByteRange(target, range, signal, axes) {
2268
+ return super.readByteRange(await this.checkedRead(target, axes), range, signal);
2269
+ }
2270
+ /**
2271
+ * Fence the listing by the per-call read axis, then delegate to the inherited
2272
+ * directory listing.
2273
+ * @param target - the resolved directory to list.
2274
+ * @param signal - aborts the listing.
2275
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2276
+ * @returns the directory entries from the inherited backend.
2277
+ */
2278
+ async listDir(target, signal, axes) {
2279
+ return super.listDir(await this.checkedRead(target, axes), signal);
2280
+ }
2281
+ /**
2282
+ * Fence the metadata read by the per-call read axis, then delegate to the
2283
+ * inherited stat.
2284
+ * @param target - the resolved target to inspect.
2285
+ * @param signal - aborts the metadata read.
2286
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2287
+ * @returns the metadata, or `undefined` when the target is absent.
2288
+ */
2289
+ async stat(target, signal, axes) {
2290
+ return super.stat(await this.checkedRead(target, axes), signal);
2291
+ }
2292
+ /**
2293
+ * Fence the no-follow metadata read by the per-call read axis, then delegate
2294
+ * to the inherited lstat with the caller's arguments unchanged.
2295
+ * @param path - the requested path, relative to `opts.cwd` or the backend cwd.
2296
+ * @param opts - optional cwd for the resolution.
2297
+ * @param signal - aborts the metadata read.
2298
+ * @param axes - the calling session's axis pair; omit for the deployment default.
2299
+ * @returns the path-entry metadata, or `undefined` when the entry is absent.
2300
+ */
2301
+ async lstat(path, opts, signal, axes) {
2302
+ await this.checkedReadPath(path, opts, axes);
2303
+ return super.lstat(path, opts, signal);
2304
+ }
2305
+ };
2306
+ //#endregion
2307
+ //#region lib/types/index.js
2308
+ /**
2309
+ * 双轴访问模式组合包(宿主半边):把文件沙箱的两条访问轴 —— 读轴与写轴,
2310
+ * 每条都取 deny | workspace | all | custom,custom 形如
2311
+ * `{ kind: 'custom', base, allow[], deny[] }`,语义是 allow = base ∪ allow − deny
2312
+ * 且 deny 优先 —— 作为一份可安装、可配置的组合层暴露出去。
2313
+ *
2314
+ * 这一层持有两件事:新会话从哪两条轴开始(本行的 Loader entry Config,见
2315
+ * `./config.ts`),以及这两条轴在一条会话里怎么存、怎么读
2316
+ * (`./session-store.ts`,本包自有的设置命名空间 `dual-axis-sessions`,按会话 id
2317
+ * 分区)。它把前者下发到后者的时机是 `ctx.on('session/created')`:0.1.7 里
2318
+ * `packages/interaction/permission-presets/src/index.ts:245` 用同一个钩子钉
2319
+ * 权限预设,本包在它之后注册,因此新会话先拿到预设的 mode,再被本包写上一对轴。
2320
+ * 那一对轴按会话来源分两种:顶层会话取设置页那一行当刻的值,子代理子会话取
2321
+ * **父会话当刻生效的那一对**(见 `./session-store.ts` 的 `inheritedAxes`)。
2322
+ * 同一个种子也是**记录缺席时那条会话被修复成的取值**(`seedAxesFor`),修复由
2323
+ * 三处活读路径上的 `store.ensure` 触发,见该方法的说明。
2324
+ *
2325
+ * 为什么轴不在会话日志里(0.1.7 上游删改所致,逐条依据见 README):
2326
+ * - 轴代数由 `./axis.ts` 与 `./scope.ts` 自带(0.1.7 全删)。
2327
+ * - 会话内的载体由本包自己声明的事件类型承担在 0.1.7 里**不成立**:该类型不在
2328
+ * `KNOWN_SESSION_EVENT_TYPES` 里,而 `Session.append` 的信封硬编码、不接
2329
+ * `ignorable`,于是写过的会话会被 `validateStoredEvents` 整份拒收、冷启动后
2330
+ * 打不开。轴因此搬到 `./session-store.ts`;日志里只保留写轴基准档经
2331
+ * `sandbox/mode` 的那一次镜像(`./session-axes.ts`)。
2332
+ * - 读路径围栏由 `./fs-fence.ts` 自己实现(0.1.7 的 fs-sandbox 只围栏写)。
2333
+ * - 设置节由本行的 Config + `.volatile()` 承载(0.1.7 删了 `installSection`
2334
+ * 与任意命名空间注册,ns 就是 entry id)。
2335
+ *
2336
+ * 客户端半边在 `@t4r71/dsh-dual-axis-ui`:本包不声明 `dsh.client`。
2337
+ *
2338
+ * @module @t4r71/dsh-dual-axis
2339
+ */
2340
+ /**
2341
+ * {@link DualAxis.inject} 声明的依赖清单。
2342
+ *
2343
+ * `sessions` 是构造期就要用的:挂载时补齐挂载前就已存在的会话。cordis 里访问
2344
+ * 未声明的服务会直接抛 `cannot get property "..." without inject`,挂载当场失败。
2345
+ * 会话投影不再需要 —— 轴不再由会话事件喂(`./session-store.ts`)。
2346
+ */
2347
+ const INJECT = ["sessions"];
2348
+ /**
2349
+ * 双轴访问模式组合层。
2350
+ *
2351
+ * 挂载时做四件事:自检行里声明的轴形状、在会话创建钩子上给新会话钉一对轴、
2352
+ * 按需回收已经消失的会话的记录、把写轴基准档镜像进日志。改完配置只管之后的
2353
+ * 会话;已经在跑的会话保持它存储里那一对轴。子代理子会话不取设置页的值,
2354
+ * 取它的父会话当刻那一对。
2355
+ */
2356
+ var DualAxis = class {
2357
+ /**
2358
+ * 这一行需要的能力:会话表(补齐挂载前的会话)。依赖是静态字段,Cordis 的
2359
+ * loader 只从插件的 `inject` 属性读它 —— 函数形态导出同名常量不会被采用。
2360
+ */
2361
+ static inject = INJECT;
2362
+ /** 本行的组合配置 schema,由 loader 在挂载前校验。 */
2363
+ static Config = Config;
2364
+ /**
2365
+ * 本行的组合配置,挂载那一刻的那一份。
2366
+ *
2367
+ * 两条轴都是 `.volatile()` 字段,取值是 schema 的 accessor;但这份 accessor
2368
+ * 是**构造期**解析出来的,0.1.7 的 loader 不把后来的设置改动提交回来,所以它
2369
+ * 只作 {@link declaredAxes} 取不到设置服务时的兜底,不再是新会话的取值来源。
2370
+ */
2371
+ config;
2372
+ /** 轴的权威存储:本包自有的设置命名空间,按会话 id 分区。 */
2373
+ store;
2374
+ /**
2375
+ * @param ctx - 宿主上下文,本层读会话表、注册会话钩子、写轴存储都经它。
2376
+ * @param config - 本行的组合配置(挂载那一刻的值)。
2377
+ */
2378
+ constructor(ctx, config) {
2379
+ this.config = config;
2380
+ axesOf(config);
2381
+ this.store = sessionAxesStore(ctx);
2382
+ ctx.inject(["systemPrompt"], (scope) => {
2383
+ scope.systemPrompt.context({
2384
+ name: "sandbox:dual-axis",
2385
+ order: scope.systemPrompt.getContextOrder("SANDBOX_POLICY") + 1,
2386
+ text: (context) => {
2387
+ const session = context.agent?.session;
2388
+ if (session === void 0) return "";
2389
+ const workspaceRoot = session.header.cwd;
2390
+ if (workspaceRoot === void 0) return "";
2391
+ const resolved = this.resolveFor(ctx, session, workspaceRoot);
2392
+ return resolved.ok ? renderScopePrompt(resolved.axes, workspaceRoot) : unresolvedAxesNotice(resolved.problem);
2393
+ }
2394
+ });
2395
+ });
2396
+ ctx.inject(["settings"], (scope) => {
2397
+ scope.effect(() => scope.settings.configure({ auto: false }, ctx.fiber));
2398
+ });
2399
+ const pin = (session) => {
2400
+ this.pin(ctx, session);
2401
+ };
2402
+ ctx.on("session/created", pin);
2403
+ for (const session of ctx.sessions.list()) pin(session);
2404
+ ctx.on("session/event", (session, event) => {
2405
+ if (!CONTENT_EVENT_TYPES.has(event.type)) return;
2406
+ const header = session.header;
2407
+ if (header === void 0) return;
2408
+ if (this.store.get(String(header.id)) !== void 0) return;
2409
+ pin(session);
2410
+ });
2411
+ this.sweepSchedule(ctx);
2412
+ registerAxisCommand(ctx, {
2413
+ current: (session) => this.store.ensure(String(session.header.id), () => this.seedFor(ctx, session), hasContent(session)),
2414
+ knownGroups: () => ruleGroupIds(groupLibraryValue(declaredSection(ctx))),
2415
+ write: async (session, axes) => {
2416
+ await this.store.set(String(session.header.id), axes);
2417
+ mirrorWriteMode(session, axes);
2418
+ const workspaceRoot = session.header.cwd;
2419
+ if (workspaceRoot !== void 0) this.resolveFor(ctx, session, workspaceRoot);
2420
+ }
2421
+ });
2422
+ }
2423
+ /**
2424
+ * 给一条会话钉上它该有的两条轴。
2425
+ *
2426
+ * 取值来源按会话来源分叉:子代理子会话继承父会话当刻那一对,其余会话取设置页
2427
+ * 那一行当刻的值。这条会话在存储里已经有记录时**一律不写**:那条记录就是这条会话
2428
+ * 自己的轴,设置页此后再改与它无关,而每一次重钉都会把用户在下拉里选过的值按设置页
2429
+ * 覆盖回去。
2430
+ *
2431
+ * **没有内容**的会话同样**不写**:不是「已经有记录」,而是「还不该有记录」。
2432
+ * 工作区选择器在同一个工作区里开新对话时复用同一条会话 id(`docs` 与 README 里
2433
+ * 记着这条实测),所以创建即冻结等于把一条还没人用过的会话按当天的设置页钉死;
2434
+ * 这条会话此后每次读取都由 `ensure` 现算种子,设置页改一行它就跟着改,直到它真的
2435
+ * 被用过。落记录的两个时机因此是:**这一条会话被用过**(`hasContent`),或者
2436
+ * **用户经 `/axis` 手动改过它的轴**(那条写路径无条件落,见 `registerAxisCommand`)。
2437
+ * @param ctx - 宿主上下文,用于读这条会话与它父会话的轴。
2438
+ * @param session - 目标会话。
2439
+ */
2440
+ pin(ctx, session) {
2441
+ const header = session.header;
2442
+ if (header === void 0) return;
2443
+ const id = String(header.id);
2444
+ const standing = this.store.get(id);
2445
+ if (standing !== void 0) {
2446
+ mirrorWriteMode(session, standing);
2447
+ return;
2448
+ }
2449
+ if (!hasContent(session)) return;
2450
+ this.store.set(id, this.seedFor(ctx, session)).catch((error) => {
2451
+ ctx.logger?.error("dual-axis: could not store the axes of session \"%s\"", id);
2452
+ ctx.logger?.error(error);
2453
+ });
2454
+ }
2455
+ /**
2456
+ * 一条会话该有的那一对轴 —— 新会话的种子,也是记录缺席时被修复成的取值。
2457
+ *
2458
+ * `pin` 与三处活读路径上的 `store.ensure` 走的是这同一个方法,所以「新建时写下的」
2459
+ * 与「后来补写的」不可能算出两个答案;没有设置服务的组合里用本行自己的 Config 兜底。
2460
+ * @param ctx - 宿主上下文。
2461
+ * @param session - 目标会话。
2462
+ * @returns 这一对轴。
2463
+ */
2464
+ seedFor(ctx, session) {
2465
+ return seedAxesFor(ctx, session, {
2466
+ axes: this.declaredAxes(ctx),
2467
+ groups: this.declaredDefaultGroups(ctx)
2468
+ });
2469
+ }
2470
+ /**
2471
+ * 这条会话当刻生效的那一对轴 —— 记录缺席时现算种子,有记录时读记录;两条轴都按
2472
+ * 引用的 id **当刻**取组定义,再求写轴 ∩ 读轴。
2473
+ *
2474
+ * 提示词、围栏与客户端显示走的是这**同一处**:解算结果里的 `narrowing` 被原样发布进
2475
+ * 这条会话的记录({@link SessionAxesStore.refreshNarrowing}),于是界面上说「被削掉了
2476
+ * 这些根」与实际执行的围栏不会有第二个答案 —— 浏览器半边既算不出 `workspace` 底座
2477
+ * (要 `os.tmpdir()`),也算不出根路径的规范拼写(要 `realpath`)。
2478
+ * @param ctx - 宿主上下文。
2479
+ * @param session - 目标会话。
2480
+ * @param workspaceRoot - 这条会话的工作区根,`workspace` 与 `custom` 底座对它的解析。
2481
+ * @returns 生效的一对轴,或解算不出来的原因。
2482
+ */
2483
+ resolveFor(ctx, session, workspaceRoot) {
2484
+ const id = String(session.header.id);
2485
+ const preset = this.store.ensure(id, () => this.seedFor(ctx, session), hasContent(session));
2486
+ const resolved = resolveEffectiveAxes(preset, referencedGroupIds(preset).length === 0 ? void 0 : groupLibraryValue(declaredSection(ctx)), { workspaceRoot });
2487
+ if (resolved.ok) this.store.refreshNarrowing(id, resolved.axes.narrowing);
2488
+ return resolved;
2489
+ }
2490
+ /**
2491
+ * 按需回收:存储里那些 id 已经不在持久化会话表里的记录。
2492
+ *
2493
+ * 起停各扫一次、此后每 {@link SWEEP_INTERVAL_MS} 一次,都是后台任务:清扫失败只记
2494
+ * 日志,绝不影响会话创建或判定路径。判据(为什么不是 `session/disposed`)见
2495
+ * {@link SessionAxesStore.sweep}。
2496
+ * @param ctx - 宿主上下文。
2497
+ */
2498
+ /**
2499
+ * 把每一条在册会话的收窄结论重新解算并发一次。
2500
+ *
2501
+ * 为什么需要这一步:收窄结论是解算的产物,而解算的输入里就有**组定义**,改一个组的
2502
+ * 规则只改设置文档、不碰任何一条会话。少了这一次重发,界面上那句「读轴削掉了这些根」
2503
+ * 就要等到这条会话下一次被触达(下一轮提示词、下一次工具调用、下一次改轴)才跟上,
2504
+ * 而人在设置页改完组之后回到对话里看下拉时,两者都还没发生。
2505
+ *
2506
+ * 判定路径不受影响:围栏与提示词一直是按 id **现取**组定义的,这里重发的只是那份
2507
+ * 已经解算出来的结论。没有记录的会话跳过 —— 它的轴本来就每次现算,落一份反而会冻住。
2508
+ * @param ctx - 宿主上下文。
2509
+ */
2510
+ republishNarrowing(ctx) {
2511
+ for (const session of ctx.sessions.list()) {
2512
+ const header = session.header;
2513
+ if (header === void 0 || header.cwd === void 0) continue;
2514
+ if (this.store.get(String(header.id)) === void 0) continue;
2515
+ this.resolveFor(ctx, session, header.cwd);
2516
+ }
2517
+ }
2518
+ sweepSchedule(ctx) {
2519
+ const sweep = () => {
2520
+ this.store.sweep().then((result) => {
2521
+ if (result !== void 0 && result.removed.length > 0) ctx.logger?.info("dual-axis: dropped the axis records of %s deleted session(s)", String(result.removed.length));
2522
+ }).catch((error) => {
2523
+ ctx.logger?.warn("dual-axis: the session axis sweep failed; every record is kept");
2524
+ ctx.logger?.warn(error);
2525
+ });
2526
+ };
2527
+ const republish = () => {
2528
+ this.republishNarrowing(ctx);
2529
+ };
2530
+ ctx.effect(() => {
2531
+ const first = setTimeout(sweep, SWEEP_FIRST_DELAY_MS);
2532
+ const repeating = setInterval(sweep, SWEEP_INTERVAL_MS);
2533
+ const firstRepublish = setTimeout(republish, NARROWING_REFRESH_MS);
2534
+ const republishing = setInterval(republish, NARROWING_REFRESH_MS);
2535
+ return () => {
2536
+ clearTimeout(first);
2537
+ clearInterval(repeating);
2538
+ clearTimeout(firstRepublish);
2539
+ clearInterval(republishing);
2540
+ };
2541
+ }, "dual-axis: session axis sweep");
2542
+ }
2543
+ /**
2544
+ * 设置页那一行当刻声明的两条轴。
2545
+ *
2546
+ * 走 `ctx.settings.describe()` 而不是构造期捕获的 `this.config`:设置页保存写的
2547
+ * 是 Loader entry 的 config 文档,而 `this.config` 是挂载那一刻解析出来的
2548
+ * volatile accessor 快照。0.1.7 的 loader 不把新值提交回插件实例持有的那一份
2549
+ * (`vendor/loader/src/config/entry.ts:162-195`),所以同一进程内保存后,读
2550
+ * `this.config` 拿到的仍是旧值,必须重启才生效。`describe()` 每次都从
2551
+ * `entry.fiber.config` 重新投影并解 volatile
2552
+ * (`packages/settings/settings/src/index.ts:319-323` +
2553
+ * `packages/settings/settings/src/schema.ts:10-17`),与设置页摘要显示用的是
2554
+ * 同一条读路径,因此这里读到的就是文件里那份值。
2555
+ *
2556
+ * `settings` 不在场的组合(本包可在没有设置服务的宿主里单独装载)退回构造期
2557
+ * 那一份,也就是没有设置页可保存时的唯一取值。
2558
+ * @param ctx - 宿主上下文。
2559
+ * @returns 两条轴。
2560
+ */
2561
+ declaredAxes(ctx) {
2562
+ const value = declaredSection(ctx);
2563
+ return axesOf(value === void 0 ? this.config : value);
2564
+ }
2565
+ /**
2566
+ * 设置页声明「新会话默认加载」的组 id。
2567
+ *
2568
+ * 只被 {@link pin} 用来播种新会话,判定路径一概不读它 —— 与 read/write 两个默认值同理,
2569
+ * 它描述的是一条**将要建立**的会话,不是任何已经有记录的会话。
2570
+ * @param ctx - 宿主上下文。
2571
+ * @returns 声明的组 id;没有设置服务时为空。
2572
+ */
2573
+ declaredDefaultGroups(ctx) {
2574
+ const value = declaredSection(ctx);
2575
+ return value === void 0 ? defaultGroupIds(this.config) : defaultGroupIds(value);
2576
+ }
2577
+ };
2578
+ /** 首次清扫的延迟:等 loader 把每个入口都激活、持久化后端也装好。 */
2579
+ const SWEEP_FIRST_DELAY_MS = 15e3;
2580
+ /** 之后每次清扫的间隔。 */
2581
+ const SWEEP_INTERVAL_MS = 3e4;
2582
+ /**
2583
+ * 两次重发收窄结论之间的毫秒数 —— 界面上那句「读轴削掉了这些根」跟随组定义的最坏延迟。
2584
+ *
2585
+ * 比回收记录的间隔短:回收只是清理,而这句话是给人读的判断,而它唯一的其他更新时机是
2586
+ * 这条会话被触达(下一轮提示词、下一次工具调用、下一次改轴)—— 人在设置页改完组之后回到
2587
+ * 对话里看下拉时,这三件事都还没发生。重发本身只在结论真的变了时才写文档。
2588
+ */
2589
+ const NARROWING_REFRESH_MS = 1e4;
2590
+ //#endregion
2591
+ export { AXES_WRITE_ATTEMPTS, AXES_WRITE_BACKOFF_MS, AXIS_BASES, AXIS_COMMAND_NAME, AXIS_KINDS, Config, DEFAULT_AXES, DEFAULT_READ_SCOPE, DEFAULT_WRITE_SCOPE, DUAL_AXIS_ROW_ID, DualAxis, DualAxis as default, DualAxisFileSystem, NARROWING_REFRESH_MS, NO_BYPASS, RETIRED_SETTINGS_NAMESPACE, SESSION_AXES_FIELD, SESSION_AXES_NAMESPACE, SWEEP_FIRST_DELAY_MS, SWEEP_INTERVAL_MS, ScopeConfigError, Config$1 as SessionAxesConfig, SessionAxesConflictError, SessionAxesStore, WIDENING_EXIT, axesOf, declaredSection, defaultGroupIds, effectiveScopes, groupLibraryValue, inheritedAxes, intersectResolved, isAbsoluteSpelling, isLexicallyUnder, isModeConsistent, isSandboxMode, mirrorWriteMode, modeOfAxisBase, modeOfScope, narrowerBase, normalizeScope, parseAxisEntry, parseSessionAxesField, parseSessionAxesRecord, readAxisRefusal, referencedGroupIds, registerAxisCommand, renderAxisRange, renderScopePrompt, resolveEffectiveAxes, resolveScope, ruleGroupIds, sameScope, scopeContains, scopeOfMode, scopeOfResolved, scopeRefusal, seedAxesFor, seedGroupReferences, seedPair, sessionAxesStore };