@t4r71/dsh-dual-axis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,978 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import "@deepseek-ai/dsh-sandbox";
3
+ //#region lib/types/axis.js
4
+ /**
5
+ * The read/write ACCESS AXES of the file sandbox: the per-axis vocabulary a
6
+ * session selects and the lossless mapping onto the legacy single `mode`.
7
+ *
8
+ * This is the 0.1.7 port of the algebra upstream deleted. 0.1.6 shipped it as
9
+ * `@deepseek-ai/dsh-sandbox/access` (152 lines) and
10
+ * `@deepseek-ai/dsh-sandbox/scope` (152 lines); 0.1.7 has neither symbol nor
11
+ * file, so the package carries its own copy and keeps the 0.1.6 semantics
12
+ * verbatim: four kinds, `custom` = base + allow - deny with deny winning.
13
+ *
14
+ * One axis answers "which absolute paths may this execution touch". `deny`
15
+ * permits nothing, `workspace` permits the calling session's workspace root
16
+ * plus the platform temp areas, `all` is the whole host filesystem, and
17
+ * `custom` names a BASE kind plus absolute path ADDITIONS and REMOVALS —
18
+ * path lists, not patterns.
19
+ *
20
+ * `mode` REMAINS the write axis's persistence spelling. The session-format
21
+ * whitelist pins its literal values, so this module keeps `mode` derivable
22
+ * from the write scope in both directions instead of replacing it:
23
+ * {@link scopeOfMode} and {@link modeOfScope} are the two halves of that
24
+ * bijection, and `custom` maps onto the mode implied by its own base.
25
+ *
26
+ * @module @t4r71/dsh-dual-axis/axis
27
+ */
28
+ /** Every {@link AxisScopeKind}, for option advertisement and untrusted-value validation. */
29
+ const AXIS_KINDS = [
30
+ "deny",
31
+ "workspace",
32
+ "all",
33
+ "custom"
34
+ ];
35
+ /** Every {@link AxisBase}, for option advertisement and untrusted-value validation. */
36
+ const AXIS_BASES = [
37
+ "deny",
38
+ "workspace",
39
+ "all"
40
+ ];
41
+ /** The default READ axis: every mode permitted reading before the axes existed, so the whole host. */
42
+ const DEFAULT_READ_SCOPE = { kind: "all" };
43
+ /** The default WRITE axis: the session workspace plus the platform temp areas. */
44
+ const DEFAULT_WRITE_SCOPE = { kind: "workspace" };
45
+ //#endregion
46
+ //#region lib/types/scope.js
47
+ /**
48
+ * Whether a configured path is spelled absolutely on this host. Both POSIX
49
+ * (`/x`) and Windows (`C:\\x`, `\\\\server\\share`) spellings are accepted
50
+ * because a policy may be authored for one world and resolved in another; the
51
+ * canonical resolution that follows is what actually binds it to this host.
52
+ * @param path - the configured path spelling.
53
+ * @returns whether the spelling is absolute.
54
+ */
55
+ function isAbsoluteSpelling(path) {
56
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
57
+ }
58
+ //#endregion
59
+ //#region lib/types/config.js
60
+ /**
61
+ * The dual-axis settings surface: the 0.1.7 spelling of the section 0.1.6
62
+ * registered through `ctx.settings.installSection`.
63
+ *
64
+ * 0.1.7 removed both `installSection` and `settings.register(ns, schema)`:
65
+ * `SettingsForms.describe()` projects exactly the Config schemas of LOADED
66
+ * Loader entries (`packages/settings/settings/src/index.ts:302-340`) and
67
+ * `write()` refuses a namespace with no matching entry
68
+ * (`:382-384`, `No configurable plugin entry`). The namespace IS the entry
69
+ * id, so this package's own row id in `cordis.patch.yml` — `dual-axis` — is
70
+ * the settings namespace. 0.1.6's separate `sandbox-axis` namespace name is
71
+ * therefore retired.
72
+ *
73
+ * 0.1.7 also added the volatile gate: a field that is not beneath a
74
+ * `.volatile()` node is neither projected into the form
75
+ * (`packages/settings/settings/src/schema.ts:37-47`) nor writable
76
+ * (`:74-78`), and an entry with no volatile field at all is refused outright
77
+ * (`index.ts:385-386`). Both axes are marked volatile here.
78
+ *
79
+ * The schema is deliberately `z.any()` per axis, exactly as in 0.1.6:
80
+ * schemastery's union types strip the `custom` branch's `base/allow/deny`
81
+ * payload, which would silently degrade a configured custom axis into an axis
82
+ * carrying no paths. Shape validation is {@link normalizeScope}'s job.
83
+ *
84
+ * @module @t4r71/dsh-dual-axis/config
85
+ */
86
+ /**
87
+ * This package's row id in `cordis.patch.yml`. It doubles as the 0.1.7
88
+ * settings namespace (the Loader entry id) and, prefixed with the package
89
+ * name, as the `plugins.row.config` slot entry key the client half uses.
90
+ */
91
+ const DUAL_AXIS_ROW_ID = "dual-axis";
92
+ z.object({
93
+ read: z.any().volatile(),
94
+ write: z.any().volatile(),
95
+ groups: z.array(z.any()).default([]).volatile(),
96
+ defaultGroups: z.array(z.string()).default([]).volatile()
97
+ });
98
+ /**
99
+ * Collect one `custom` axis's path list: every entry must be a non-empty
100
+ * absolute path.
101
+ * @param label - the axis name used in the error message.
102
+ * @param entry - the list name used in the error message (`allow` or `deny`).
103
+ * @param value - the untrusted list value.
104
+ * @returns the validated path list.
105
+ * @throws When the value is not a string array, or holds a non-absolute path.
106
+ */
107
+ function pathList(label, entry, value) {
108
+ if (value === void 0) return [];
109
+ 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`);
110
+ 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`);
111
+ return [...value];
112
+ }
113
+ /**
114
+ * Coerce one axis value from a config document (which a human may have edited)
115
+ * into a legal {@link AxisScope}, throwing rather than guessing.
116
+ *
117
+ * An unreadable axis must not become some other, wider or narrower grant:
118
+ * throwing fails the row's mount or the settings write on the spot instead of
119
+ * proceeding under an invented boundary. This is 0.1.6's `normalizeScope`,
120
+ * unchanged.
121
+ * @param label - the axis name used in error messages (`read` or `write`).
122
+ * @param value - the untrusted axis value.
123
+ * @returns the validated axis value.
124
+ * @throws When the value is not one of the four kinds, or a custom entry is not absolute.
125
+ */
126
+ function normalizeScope(label, value) {
127
+ if (typeof value !== "object" || value === null) throw new Error(`dual-axis: ${label} must be an access-axis object, received ${JSON.stringify(value)}`);
128
+ const candidate = value;
129
+ 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(", ")})`);
130
+ if (candidate.kind !== "custom") return { kind: candidate.kind };
131
+ 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)}`);
132
+ return {
133
+ kind: "custom",
134
+ base: candidate.base,
135
+ groups: groupIds(label, candidate.groups),
136
+ allow: pathList(label, "allow", candidate.allow),
137
+ deny: pathList(label, "deny", candidate.deny)
138
+ };
139
+ }
140
+ /**
141
+ * Collect one `custom` axis's rule-group references. Only the speaker matters
142
+ * here — an id is a name the settings page resolves, so this checks that the
143
+ * list is a list of non-empty names and nothing more; whether the name exists is
144
+ * decided at the moment of use ({@link resolveEffectiveAxes}), where a missing
145
+ * one can be refused instead of quietly dropped.
146
+ * @param label - the axis name used in the error message.
147
+ * @param value - the untrusted `groups` value.
148
+ * @returns the validated id list.
149
+ * @throws When the value is not an array of non-empty strings.
150
+ */
151
+ function groupIds(label, value) {
152
+ if (value === void 0) return [];
153
+ 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`);
154
+ return [...new Set(value)];
155
+ }
156
+ /**
157
+ * Read both axes out of one settings/config value.
158
+ *
159
+ * The parameter is UNTRUSTED on purpose: the same function serves the
160
+ * composition layer's parsed Config, the settings service's projected
161
+ * descriptor (an `unknown` on the wire between two packages), and a
162
+ * hand-edited document, and none of those three is guaranteed to carry the
163
+ * declared shape. Both axes therefore pass through {@link normalizeScope},
164
+ * which is where an illegal value becomes a throw instead of an invented
165
+ * boundary; a missing side falls back to the built-in default axis.
166
+ * @param section - the section's current resolved value, whatever carries it.
167
+ * @returns both axes.
168
+ * @throws When either axis's shape is illegal.
169
+ */
170
+ function axesOf(section) {
171
+ const declared = section === null || typeof section !== "object" ? {} : section;
172
+ return {
173
+ read: normalizeScope("read", unwrapVolatile(declared.read) ?? DEFAULT_READ_SCOPE),
174
+ write: normalizeScope("write", unwrapVolatile(declared.write) ?? DEFAULT_WRITE_SCOPE)
175
+ };
176
+ }
177
+ /**
178
+ * Unwrap one config field's value.
179
+ *
180
+ * A `.volatile()` field is handed to the plugin as a cordis `Volatile<T>`
181
+ * **accessor**, not as `T`: the schema's `get()` re-reads the stored value on
182
+ * every call so an edit applies without remounting the entry. Reading the
183
+ * property directly therefore yields the accessor object, whose `kind` is
184
+ * `undefined` — which {@link normalizeScope} correctly refuses, failing the
185
+ * whole row at mount. The upstream volatile Config
186
+ * (`packages/core/agent-default-model/src/index.ts:68-71`) unwraps the same way.
187
+ *
188
+ * A hand-written config document may also carry the plain value, so both
189
+ * spellings are accepted.
190
+ * @param value - the configured field, as the accessor, as a plain value, or as
191
+ * whatever an untrusted document put under the key.
192
+ * @returns the underlying value, or `undefined` when this field is unset.
193
+ */
194
+ function unwrapVolatile(value) {
195
+ if (value === void 0) return void 0;
196
+ if (typeof value.get === "function") return value.get();
197
+ return value;
198
+ }
199
+ /**
200
+ * This row's current value on the settings page, or `undefined` when no
201
+ * settings service is mounted.
202
+ *
203
+ * This is the ONE read path to that row. It goes through `describe()` rather
204
+ * than the config the plugin instance captured at mount because the loader does
205
+ * not commit a later save back to that instance
206
+ * (`vendor/loader/src/config/entry.ts:162-195`); `describe()` re-projects
207
+ * `entry.fiber.config` and unwraps the volatile accessors on every call
208
+ * (`packages/settings/settings/src/index.ts:319-323` +
209
+ * `packages/settings/settings/src/schema.ts:11`), which is the same path the
210
+ * settings page renders from. A save therefore applies without a restart.
211
+ * @param ctx - host context; the service is looked up, never injected.
212
+ * @returns the row's current value, or `undefined` when unavailable.
213
+ */
214
+ function declaredSection(ctx) {
215
+ return ctx.get("settings")?.describe().find((row) => row.ns === DUAL_AXIS_ROW_ID)?.value;
216
+ }
217
+ /**
218
+ * The group ids a newly created session starts out referencing.
219
+ * @param section - the row's current value, as {@link declaredSection} returns it.
220
+ * @returns the ids, deduplicated; empty when the row declares none.
221
+ * @throws When a declared id is not a non-empty string.
222
+ */
223
+ function defaultGroupIds(section) {
224
+ return groupIds("defaultGroups", section === null || typeof section !== "object" ? void 0 : unwrapVolatile(section.defaultGroups));
225
+ }
226
+ //#endregion
227
+ //#region lib/types/groups.js
228
+ /**
229
+ * RULE GROUPS and THE ONE resolution from a session's axis preset to the ranges
230
+ * actually in force.
231
+ *
232
+ * A rule group is a named fragment of path rules owned by the settings page
233
+ * (`dual-axis`'s `groups` Config field). A session references groups BY ID and
234
+ * never stores their content: ten sessions selecting one group must not become
235
+ * ten copies of it, because copies drift. Resolving an id against the CURRENT
236
+ * library is therefore the only place a group's rules enter a decision, and
237
+ * editing a group takes effect on every session referencing it without a
238
+ * restart.
239
+ *
240
+ * This module is that place, for both axes and for both invariants that ride on
241
+ * them:
242
+ *
243
+ * - **deny wins over allow**, with session-level entries and every selected
244
+ * group merged into ONE allow list and ONE deny list first
245
+ * ({@link resolveEffectiveAxes}), so a group's removal cannot be overridden by
246
+ * a session-level addition.
247
+ * - **write never exceeds read**: the effective write range is the write axis's
248
+ * range INTERSECTED with the read axis's range. A write outside the read range
249
+ * would let an agent overwrite a file it cannot read back, so the intersection
250
+ * is computed here — in the resolution — and never in a user interface.
251
+ *
252
+ * Both the fence and the model-facing prompt call {@link resolveEffectiveAxes},
253
+ * so the range the model is told about and the range it is held to are the same
254
+ * computation over the same inputs: the session's own preset (read and write
255
+ * axes, their own additions and removals, and the group ids they selected) plus
256
+ * the current definitions of those ids. The settings page's `read` / `write`
257
+ * defaults and its `defaultGroups` seed are NOT among those inputs: they are
258
+ * read once, when a session is created.
259
+ *
260
+ * A reference to an id the library does not define is refused, never treated as
261
+ * an empty group: "I excluded it and nothing happened" is the worst failure this
262
+ * surface can have.
263
+ *
264
+ * @module @t4r71/dsh-dual-axis/groups
265
+ */
266
+ /** The narrowing that removed nothing. Exported: it is the stored record's absent value. */
267
+ const NO_NARROWING = {
268
+ narrowed: false,
269
+ droppedRoots: [],
270
+ lostUnbounded: false
271
+ };
272
+ /**
273
+ * Read one stored narrowing report, or throw naming the member that is
274
+ * unreadable.
275
+ *
276
+ * An absent member is the legal "nothing was ever published for this record"
277
+ * state and yields {@link NO_NARROWING}: that is exactly what the fence enforced
278
+ * before this member existed, so the surface shows no narrowing rather than
279
+ * inventing one. Anything PRESENT must be well formed — a report whose root list
280
+ * cannot be read would otherwise be displayed as "nothing was removed".
281
+ * @param value - the untrusted `narrowing` member of a stored record.
282
+ * @returns the validated report.
283
+ * @throws When the member is present and not a well-formed report.
284
+ */
285
+ function parseNarrowing(value) {
286
+ if (value === void 0) return NO_NARROWING;
287
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("dual-axis: a stored narrowing report must be an object");
288
+ const candidate = value;
289
+ if (typeof candidate.narrowed !== "boolean" || typeof candidate.lostUnbounded !== "boolean") throw new Error("dual-axis: a stored narrowing report needs boolean narrowed and lostUnbounded");
290
+ return {
291
+ narrowed: candidate.narrowed,
292
+ droppedRoots: pathList("stored narrowing", "droppedRoots", candidate.droppedRoots),
293
+ lostUnbounded: candidate.lostUnbounded
294
+ };
295
+ }
296
+ /**
297
+ * Attach the group ids a NEW session starts out referencing to the axes that are
298
+ * `custom`, leaving every other axis exactly as declared.
299
+ *
300
+ * The rule is judged per axis, and only an axis whose OWN kind is `custom` can
301
+ * carry a reference: checking the settings row instead (a tick that is not
302
+ * tied to an axis) promoted `read: all` to `read: { kind: 'custom', base: 'all' }`
303
+ * even though no axis was ever set to custom. An axis of one of the three closed
304
+ * kinds has nowhere to put an id and is seeded verbatim — promoting it would
305
+ * report a custom scope the settings page never showed.
306
+ *
307
+ * A group may carry rules for either axis or both, and the session selected it
308
+ * once, so the same list goes on both `custom` axes; the group's `read` rules
309
+ * then apply where they exist and its `write` rules where they exist. Two axes
310
+ * are therefore independent: one may take the references while the other keeps
311
+ * its closed kind.
312
+ * @param axes - the pair a new session is seeded with.
313
+ * @param ids - the settings page's `defaultGroups`.
314
+ * @returns the pair carrying those references.
315
+ */
316
+ function seedGroupReferences(axes, ids) {
317
+ if (ids.length === 0) return axes;
318
+ const attach = (axis) => axis.kind === "custom" ? {
319
+ ...axis,
320
+ groups: [.../* @__PURE__ */ new Set([...axis.groups ?? [], ...ids])]
321
+ } : axis;
322
+ return {
323
+ read: attach(axes.read),
324
+ write: attach(axes.write)
325
+ };
326
+ }
327
+ //#endregion
328
+ //#region lib/types/session-store.js
329
+ /**
330
+ * THE per-session axis store, outside the session log.
331
+ *
332
+ * The axes cannot live in a session's own log: this package's own event type is
333
+ * not in 0.1.7's `KNOWN_SESSION_EVENT_TYPES`, and `Session.append` takes no
334
+ * envelope option, so the event cannot be marked `ignorable` and a log carrying
335
+ * it is refused WHOLE by `validateStoredEvents`
336
+ * (`packages/session/session-persistence/src/storage-contract.ts:75-77`).
337
+ * A session this package had written to could therefore not be opened at all
338
+ * after a restart.
339
+ *
340
+ * The axes live instead in this package's OWN settings namespace,
341
+ * `dual-axis-sessions`, partitioned by session id. That namespace is a
342
+ * separate Loader entry from `dual-axis` because the row's namespace carries the
343
+ * settings page's read/write/defaultGroups form: an undeclared field inside that
344
+ * row would be projected into the row's form. This namespace declares exactly one
345
+ * field and the settings page never renders it.
346
+ *
347
+ * `sandbox/mode` REMAINS the write axis's legal landing place in the log — see
348
+ * `./session-axes.ts`, which mirrors the write base onto it. The log therefore
349
+ * still records the containment the operating-system layer enforces; what it no
350
+ * longer records is the path-level axis pair, which is this module's.
351
+ *
352
+ * A session with NO record is neither an error state nor a reason to hide a
353
+ * control: every live decision path reads through {@link SessionAxesStore.ensure},
354
+ * which answers {@link seedAxesFor} — the deployment seed a newly created session
355
+ * is pinned with — and hands that SAME value to the ONE repair write it
356
+ * schedules. Such a session therefore runs under the deployment's seed from its
357
+ * first read, not under {@link DEFAULT_AXES} until the record lands, and the pair
358
+ * a picker shows before the record exists is the pair the fence and the prompt
359
+ * are already enforcing (the client half recomputes that seed from the same row —
360
+ * see `sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`).
361
+ * {@link DEFAULT_AXES} is now only the answer {@link SessionAxesStore.getOr} gives
362
+ * a caller that holds no session to build a seed from.
363
+ *
364
+ * A session with no record that has ALSO never been used is a different case, and
365
+ * it is the one the workspace picker creates every time: reopening the same
366
+ * workspace reopens the same session id, so freezing it at creation would pin the
367
+ * axes of a conversation nobody has had to whatever the settings row said that
368
+ * day. Its record is therefore written when it is USED ({@link hasContent}), not
369
+ * when it is created: until then every read recomputes the seed from the row as
370
+ * it stands NOW, and the dropdown follows the settings page in real time.
371
+ *
372
+ * @module @t4r71/dsh-dual-axis/session-store
373
+ */
374
+ /**
375
+ * The settings namespace holding every session's axis pair. It is a Loader entry
376
+ * id (`cordis.patch.yml`), which is what `SettingsForms.write` looks up.
377
+ */
378
+ const SESSION_AXES_NAMESPACE = "dual-axis-sessions";
379
+ /** The single Config field: session id -> axis pair. */
380
+ const SESSION_AXES_FIELD = "axes";
381
+ /**
382
+ * This entry's composition config. The field is `.volatile()` because 0.1.7
383
+ * refuses an entry with no volatile field outright
384
+ * (`packages/settings/settings/src/index.ts:385-386`) and because path writes
385
+ * are refused for every non-volatile path (`:387-389`).
386
+ *
387
+ * `z.dict(z.any())` rather than a per-session object schema: the keys are session
388
+ * ids, which no schema can enumerate, and the VALUES are validated by
389
+ * {@link parseSessionAxesField}, where an unreadable axis becomes a throw rather
390
+ * than an invented boundary — the same rule the row's own axes follow.
391
+ *
392
+ * Annotated rather than inferred: `z.dict`'s output names `Dict` from
393
+ * `@deepseek-ai/cosmokit`, and declaration emit refuses a type it can only reach
394
+ * through a nested `node_modules` path (TS2883). The annotation states the same
395
+ * type through that package's own entry.
396
+ */
397
+ const Config = z.object({ axes: z.dict(z.any()).default({}).volatile() });
398
+ /**
399
+ * The pair {@link SessionAxesStore.getOr} answers for a session with no stored
400
+ * record, and nothing else.
401
+ *
402
+ * It is deliberately NOT what a session with no record is held to any more:
403
+ * every live read goes through {@link SessionAxesStore.ensure}, which answers the
404
+ * deployment's seed ({@link seedAxesFor}), so the pair in force is the pair the
405
+ * session was created with rather than the built-in one. Reaching for this
406
+ * constant on a decision path would hold a session to a boundary the settings
407
+ * page never chose — narrower than an `all` default, wider than a `deny` one.
408
+ * @see SessionAxesStore.getOr
409
+ */
410
+ const DEFAULT_AXES = {
411
+ read: DEFAULT_READ_SCOPE,
412
+ write: DEFAULT_WRITE_SCOPE
413
+ };
414
+ /**
415
+ * The axis pair a session with no stored record is REPAIRED to — the same seed a
416
+ * newly created session is pinned with.
417
+ *
418
+ * Repairing to the standing pair instead (`DEFAULT_AXES`) would be narrower but wrong: a
419
+ * session whose creation-time write was lost would keep the built-in defaults
420
+ * for the rest of its life, and the settings page's seed would never reach it.
421
+ * Repairing to the seed keeps `pin`'s retry semantics intact — the retry just
422
+ * happens on the next touch of the session instead of only at creation.
423
+ *
424
+ * The seed is computed from two synchronous reads: the settings row's
425
+ * `read`/`write`/`defaultGroups` (seeds for a session that has none of its own)
426
+ * and, for a subagent child, the parent's pair AS IT STANDS NOW. A parent with
427
+ * no record of its own yields `undefined` from {@link inheritedAxes}, which is
428
+ * the same answer `pin` gets, so parent and child agree in that case too.
429
+ * @param ctx - host context; carries the settings service the seed is read from.
430
+ * @param session - the session to seed.
431
+ * @param fallback - the composing plugin's own config, used only when no settings
432
+ * service is mounted (where the namespace cannot be written anyway).
433
+ * @returns the pair a newly created session would have been pinned with.
434
+ */
435
+ function seedAxesFor(ctx, session, fallback) {
436
+ const section = declaredSection(ctx);
437
+ const inherited = inheritedAxes(ctx, session);
438
+ if (section === void 0) return seedGroupReferences(inherited ?? fallback?.axes ?? DEFAULT_AXES, fallback?.groups ?? []);
439
+ return seedPair(section, inherited);
440
+ }
441
+ /**
442
+ * The pure core of {@link seedAxesFor}: one settings row value, plus the pair a
443
+ * subagent child inherits, into the pair a session with no record is seeded with.
444
+ *
445
+ * It is TOTAL on purpose, and the client half mirrors it step for step
446
+ * (`sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`, pinned by
447
+ * `tests/seed-parity.spec.ts`): an unreadable row, and an unreadable inherited
448
+ * pair, answer {@link DEFAULT_AXES}. That is also what a live read answers — see
449
+ * {@link SessionAxesStore.ensure} — so the pair a picker shows for a session with
450
+ * no record and the pair the host enforces are one value rather than two.
451
+ * @param row - the settings row's value, untrusted: a human may have edited it.
452
+ * @param inherited - the parent's pair for a subagent child, or `undefined`.
453
+ * @returns the seed pair.
454
+ */
455
+ function seedPair(row, inherited) {
456
+ try {
457
+ return seedGroupReferences(inherited === void 0 ? axesOf(row) : pairOf(inherited), defaultGroupIds(row));
458
+ } catch {
459
+ return DEFAULT_AXES;
460
+ }
461
+ }
462
+ /**
463
+ * Re-normalize a pair another reader produced.
464
+ *
465
+ * A pair this package stored is already normalized; a hand-edited document's is
466
+ * not, and the inherited pair the client half reads comes straight off the wire.
467
+ * Running both through the same validation is what makes the two halves agree on
468
+ * the bytes, not merely on the kinds.
469
+ * @param pair - the untrusted pair.
470
+ * @returns the normalized pair.
471
+ * @throws When either member is not a legal axis value.
472
+ */
473
+ function pairOf(pair) {
474
+ return {
475
+ read: normalizeScope("read", pair.read),
476
+ write: normalizeScope("write", pair.write)
477
+ };
478
+ }
479
+ /**
480
+ * The pair a **subagent child session** inherits, or `undefined` when this is not
481
+ * a child or its parent holds no record.
482
+ *
483
+ * A child does not read the settings page: 0.1.7 deliberately seeds a child with
484
+ * its parent's explicit per-session override, and the parent's STORED pair is the
485
+ * only copy of that override this package has. Returning `undefined` (rather than
486
+ * inventing one) leaves the caller on the settings seed, which is what the child
487
+ * would have received had the parent never overridden anything — and is exactly
488
+ * the pair an un-recorded parent is itself held to.
489
+ *
490
+ * The record is looked up by the id the child's header names. The parent's own
491
+ * Session object is NOT required: requiring it made the answer depend on whether
492
+ * the parent happened to be resident in this process, and the client half — which
493
+ * reads the same document by the same id — cannot observe residency, so the two
494
+ * would disagree for a child whose parent is not materialized.
495
+ * @param ctx - host context carrying the axis store.
496
+ * @param session - the session whose parent to read.
497
+ * @returns the parent's pair, or `undefined`.
498
+ */
499
+ function inheritedAxes(ctx, session) {
500
+ if (session.header.origin !== "subagent") return void 0;
501
+ const parentId = session.header.parentSession;
502
+ if (parentId === void 0) return void 0;
503
+ return sessionAxesStore(ctx).get(String(parentId));
504
+ }
505
+ /** How many times one write re-reads the revision after a conflict. */
506
+ const AXES_WRITE_ATTEMPTS = 4;
507
+ /** Milliseconds before attempt N is issued, N counted from zero. */
508
+ const AXES_WRITE_BACKOFF_MS = [
509
+ 0,
510
+ 25,
511
+ 75,
512
+ 200
513
+ ];
514
+ /**
515
+ * A whole-document write was refused because another writer moved the settings
516
+ * document between this writer's read and its write.
517
+ *
518
+ * It is thrown, never swallowed: the axis a person just picked is not in force
519
+ * unless it was stored, and reporting success for a lost write would leave the
520
+ * fence enforcing a range the picker does not show.
521
+ */
522
+ var SessionAxesConflictError = class extends Error {
523
+ /** Stable machine code for callers that map failures to their own taxonomy. */
524
+ code = "DUAL_AXIS_AXES_CONFLICT";
525
+ /** The namespace whose write was refused. */
526
+ ns = SESSION_AXES_NAMESPACE;
527
+ /** Attempts made before giving up. */
528
+ attempts;
529
+ /**
530
+ * @param cause - the last conflict the settings service raised.
531
+ * @param attempts - attempts made before giving up.
532
+ */
533
+ constructor(cause, attempts) {
534
+ super("dual-axis: the settings document changed under this writer " + String(attempts) + " times while storing a session axis pair; nothing was written", { cause });
535
+ this.name = "SessionAxesConflictError";
536
+ this.attempts = attempts;
537
+ }
538
+ };
539
+ /**
540
+ * Whether two narrowing reports say the same thing, so a republish that changes
541
+ * nothing does not write the document.
542
+ * @param left - one report, absent when nothing was ever published.
543
+ * @param right - the other, absent under the same condition.
544
+ * @returns whether both report the same removal.
545
+ */
546
+ function sameNarrowing(left, right) {
547
+ if (left === void 0 || right === void 0) return false;
548
+ return left.narrowed === right.narrowed && left.lostUnbounded === right.lostUnbounded && left.droppedRoots.length === right.droppedRoots.length && left.droppedRoots.every((root) => right.droppedRoots.includes(root));
549
+ }
550
+ /**
551
+ * Whether a value is a plain data object.
552
+ * @param value - the candidate.
553
+ * @returns whether it is an object that is not null and not an array.
554
+ */
555
+ function isRecord(value) {
556
+ return typeof value === "object" && value !== null && !Array.isArray(value);
557
+ }
558
+ /**
559
+ * Read one stored record into an axis pair, or throw naming the member that is
560
+ * unreadable.
561
+ * @param id - the session id the record belongs to, for the error message.
562
+ * @param value - the untrusted record.
563
+ * @returns the validated pair.
564
+ * @throws When either member is not a legal axis value.
565
+ */
566
+ function parseSessionAxesRecord(id, value) {
567
+ if (!isRecord(value)) throw new Error("dual-axis: the stored axes of session " + JSON.stringify(id) + " must be an object");
568
+ const narrowing = parseNarrowing(value.narrowing);
569
+ return {
570
+ read: normalizeScope("read", value.read ?? DEFAULT_READ_SCOPE),
571
+ write: normalizeScope("write", value.write ?? DEFAULT_WRITE_SCOPE),
572
+ ...narrowing.narrowed ? { narrowing } : {}
573
+ };
574
+ }
575
+ /**
576
+ * Read the whole stored field. A human may have edited the settings document, so
577
+ * an unreadable entry throws instead of being dropped: dropping it would silently
578
+ * widen that session back to the deployment defaults.
579
+ * @param value - the untrusted `axes` field.
580
+ * @returns every stored pair, by session id.
581
+ * @throws When the field is not an object of readable records.
582
+ */
583
+ function parseSessionAxesField(value) {
584
+ if (value === void 0 || value === null) return {};
585
+ if (!isRecord(value)) throw new Error("dual-axis: the stored session axes field must be an object");
586
+ const parsed = {};
587
+ for (const [id, record] of Object.entries(value)) parsed[id] = parseSessionAxesRecord(id, record);
588
+ return parsed;
589
+ }
590
+ /**
591
+ * The stored pair as this package spells it everywhere else. The stored record is
592
+ * already a pair; this exists so the store's return type is the shared one and a
593
+ * caller cannot start depending on the record's identity.
594
+ * @param record - the stored pair.
595
+ * @returns the same pair.
596
+ */
597
+ function scopesOf(record) {
598
+ return {
599
+ read: record.read,
600
+ write: record.write
601
+ };
602
+ }
603
+ /**
604
+ * The read and write face of {@link SESSION_AXES_NAMESPACE}, plus the sweep that
605
+ * keeps it from growing with the sessions that no longer exist.
606
+ *
607
+ * Reads go through `settings.describe()`, the same projection the settings page
608
+ * renders from, and are memoized on the namespace's `revision` — which 0.1.7 bumps
609
+ * exactly when that entry's stored profile override changes
610
+ * (`packages/settings/settings/src/index.ts:311-317`). A decision path therefore
611
+ * pays one `describe()` per document change, not one per tool dispatch.
612
+ */
613
+ var SessionAxesStore = class {
614
+ ctx;
615
+ options;
616
+ cachedRevision;
617
+ cachedRecords = {};
618
+ /** Parsed records by the JSON spelling that produced them. */
619
+ bySpelling = /* @__PURE__ */ new Map();
620
+ /**
621
+ * Session ids with a repair write outstanding (or already landed) since the
622
+ * record was found missing. Prevents one repair per tool dispatch while the
623
+ * whole-document write is in flight; cleared when a write FAILS so the next
624
+ * touch retries instead of leaving the session on the defaults forever.
625
+ */
626
+ repairing = /* @__PURE__ */ new Set();
627
+ /**
628
+ * The last narrowing report this store handed to a write, by session id. A
629
+ * decision path re-resolves on every touch, so this is what keeps it from
630
+ * queueing the same document write over and over; it is cleared when a write
631
+ * lands (or fails), never on a read.
632
+ */
633
+ narrowingMemo = /* @__PURE__ */ new Map();
634
+ /** Sessions with a narrowing write outstanding right now. */
635
+ narrowingWriting = /* @__PURE__ */ new Set();
636
+ /**
637
+ * @param ctx - host context; the settings service is looked up, never injected,
638
+ * so a composition without settings still loads this package's fence.
639
+ * @param options - retry policy and the sleep used between attempts.
640
+ */
641
+ constructor(ctx, options = {}) {
642
+ this.ctx = ctx;
643
+ this.options = {
644
+ attempts: options.attempts ?? 4,
645
+ backoffMs: options.backoffMs ?? AXES_WRITE_BACKOFF_MS,
646
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => {
647
+ setTimeout(resolve, ms);
648
+ }))
649
+ };
650
+ }
651
+ /**
652
+ * This namespace's current settings row, or `undefined` when no settings
653
+ * service is mounted or the entry is not composed.
654
+ * @returns the descriptor carrying the value and the revision.
655
+ */
656
+ descriptor() {
657
+ const settings = this.ctx.get("settings");
658
+ if (settings === void 0) return void 0;
659
+ return settings.describe().find((row) => row.ns === SESSION_AXES_NAMESPACE);
660
+ }
661
+ /**
662
+ * Every stored pair, by session id.
663
+ * @returns the parsed field.
664
+ * @throws When the document carries an unreadable record.
665
+ */
666
+ records() {
667
+ const descriptor = this.descriptor();
668
+ if (descriptor === void 0) return {};
669
+ if (descriptor.revision === this.cachedRevision) return this.cachedRecords;
670
+ const value = isRecord(descriptor.value) ? descriptor.value[SESSION_AXES_FIELD] : void 0;
671
+ const spelling = JSON.stringify(value ?? null);
672
+ let records = this.bySpelling.get(spelling);
673
+ if (records === void 0) {
674
+ records = parseSessionAxesField(value);
675
+ this.bySpelling.set(spelling, records);
676
+ }
677
+ this.cachedRevision = descriptor.revision;
678
+ this.cachedRecords = records;
679
+ return records;
680
+ }
681
+ /**
682
+ * One session's stored pair.
683
+ * @param sessionId - the session whose axes to read.
684
+ * @returns the pair, or `undefined` when this session has no record yet.
685
+ */
686
+ get(sessionId) {
687
+ const record = this.records()[sessionId];
688
+ return record === void 0 ? void 0 : scopesOf(record);
689
+ }
690
+ /**
691
+ * One session's stored pair, or the built-in defaults.
692
+ *
693
+ * The answer for a caller that has no session to build a seed from, and the only
694
+ * remaining reader of {@link DEFAULT_AXES}. Every live path reads {@link ensure}
695
+ * instead, whose un-recorded answer is the session's own seed.
696
+ * @param sessionId - the session whose axes to read.
697
+ * @returns the pair in force.
698
+ */
699
+ getOr(sessionId) {
700
+ return this.get(sessionId) ?? DEFAULT_AXES;
701
+ }
702
+ /**
703
+ * One session's pair, with an un-recorded session scheduled for repair.
704
+ *
705
+ * This is the read every LIVE touch point uses (the model-facing prompt, the
706
+ * read fence, and `/axis`). A session that has a record answers it; a session
707
+ * that has none answers {@link seedAxesFor} — the pair `pin` writes for a newly
708
+ * created session — and that ONE value is both this read's answer and what the
709
+ * scheduled repair stores. The answer and the write therefore cannot disagree,
710
+ * and a session created under a saved default runs under that default from its
711
+ * FIRST turn instead of waiting for the record to land. The record is what
712
+ * answers from the moment it lands.
713
+ *
714
+ * An un-recorded session is only REPAIRED when it has been used
715
+ * ({@link hasContent}). Until then the seed is recomputed on every read, so the
716
+ * settings row stays live: a session the workspace picker reopens before anyone
717
+ * has sent anything follows the settings page, which is the whole point of not
718
+ * having a record yet. The moment a turn lands, the next read repairs the
719
+ * session and it freezes — the same seed it answered with, computed from the row
720
+ * as it then stood.
721
+ *
722
+ * The seed is a thunk because only the caller can build it (it needs the
723
+ * session, and the composing plugin's config for a settings-less host). It is
724
+ * evaluated exactly once per un-recorded read — those reads are a prompt
725
+ * assembly, a tool dispatch and a pick, never a hot loop — and it must have no
726
+ * side effect, because its value IS this read's answer.
727
+ * @param sessionId - the session whose axes to read.
728
+ * @param seed - builds the pair a repair persists AND this read answers; never
729
+ * called when a record exists.
730
+ * @param frozen - whether the session has been used, so its pair must be
731
+ * written rather than recomputed. Defaults to `true`: a caller that cannot
732
+ * judge the session's content keeps the pre-existing freeze-on-first-touch
733
+ * behaviour, which is the safe direction — it never widens a live session's
734
+ * axes behind the person's back.
735
+ * @returns the pair in force right now.
736
+ */
737
+ ensure(sessionId, seed, frozen = true) {
738
+ const standing = this.get(sessionId);
739
+ if (standing !== void 0) return standing;
740
+ let seeded;
741
+ try {
742
+ seeded = seed();
743
+ } catch {
744
+ if (frozen) this.scheduleRepair(sessionId, seed);
745
+ return DEFAULT_AXES;
746
+ }
747
+ if (frozen) this.scheduleRepair(sessionId, () => seeded);
748
+ return seeded;
749
+ }
750
+ /**
751
+ * Publish the narrowing a live read just resolved, when the record does not
752
+ * already carry it.
753
+ *
754
+ * This is how the client half learns what the read axis removed from the write
755
+ * range without re-implementing the path algebra: it reads this member out of
756
+ * the same document its two dropdowns already read. The value comes from the
757
+ * very resolution the prompt and the fence used, so the surfaced narrowing and
758
+ * the enforced one are one computation rather than two that must agree.
759
+ *
760
+ * Called on every touch of a session that references groups, so an edit to a
761
+ * group's rules is republished by the next touch instead of waiting for the
762
+ * record to be rewritten for another reason. A session with no record is left
763
+ * alone: its axes are recomputed from the settings row on every read, and a
764
+ * store of its own would freeze that. Writes are fire-and-forget — a caller is
765
+ * a synchronous decision path and its answer must not depend on a document
766
+ * write.
767
+ * @param sessionId - the session whose narrowing to publish.
768
+ * @param narrowing - the value {@link resolveEffectiveAxes} returned for it.
769
+ */
770
+ refreshNarrowing(sessionId, narrowing) {
771
+ const record = this.records()[sessionId];
772
+ if (record === void 0) return;
773
+ const stored = record.narrowing;
774
+ if (stored === void 0 && !narrowing.narrowed) return;
775
+ if (sameNarrowing(stored, narrowing)) {
776
+ this.narrowingMemo.delete(sessionId);
777
+ return;
778
+ }
779
+ if (sameNarrowing(this.narrowingMemo.get(sessionId), narrowing)) return;
780
+ this.narrowingMemo.set(sessionId, narrowing);
781
+ this.writeNarrowing(sessionId, narrowing);
782
+ }
783
+ /**
784
+ * Store one narrowing report for a session that already has a record, at most
785
+ * once per value.
786
+ *
787
+ * A failed write only forgets what was attempted: the next touch republishes
788
+ * the same report, and the dropdowns keep showing the last stored one instead
789
+ * of nothing. It never touches the axes, so a refresh that loses its race
790
+ * cannot revert a pick.
791
+ * @param sessionId - the session to update.
792
+ * @param narrowing - the report to store.
793
+ */
794
+ writeNarrowing(sessionId, narrowing) {
795
+ if (this.narrowingWriting.has(sessionId)) return;
796
+ this.narrowingWriting.add(sessionId);
797
+ Promise.resolve().then(async () => {
798
+ const record = this.records()[sessionId];
799
+ if (record === void 0) return;
800
+ const stored = record.narrowing;
801
+ if (stored === void 0 && !narrowing.narrowed) return;
802
+ if (sameNarrowing(stored, narrowing)) return;
803
+ const { narrowing: _retired, ...axes } = record;
804
+ await this.replaceRecords({
805
+ ...this.records(),
806
+ [sessionId]: {
807
+ ...axes,
808
+ ...narrowing.narrowed ? { narrowing } : {}
809
+ }
810
+ });
811
+ }).catch((error) => {
812
+ this.ctx.logger?.warn("dual-axis: could not store the narrowing of session \"%s\"; the dropdowns keep the last stored report", sessionId);
813
+ this.ctx.logger?.warn(error);
814
+ }).finally(() => {
815
+ this.narrowingWriting.delete(sessionId);
816
+ this.narrowingMemo.delete(sessionId);
817
+ });
818
+ }
819
+ /**
820
+ * Store one session's pair, whole-document, and report a lost write loudly.
821
+ *
822
+ * A single `replace` of the field: removal needs the whole map anyway, and a
823
+ * per-id path write cannot prune. Every attempt re-reads the revision, so an
824
+ * edit that landed between the read and the write is retried against the value
825
+ * it produced instead of being overwritten from a stale snapshot.
826
+ * @param sessionId - the session the pair belongs to.
827
+ * @param axes - the pair to store.
828
+ * @throws {SessionAxesConflictError} when every attempt lost the revision race.
829
+ * @throws When no settings service serves this namespace.
830
+ */
831
+ async set(sessionId, axes, narrowing) {
832
+ await this.replaceRecords({
833
+ ...this.records(),
834
+ [sessionId]: {
835
+ read: axes.read,
836
+ write: axes.write,
837
+ ...narrowing === void 0 || !narrowing.narrowed ? {} : { narrowing }
838
+ }
839
+ });
840
+ if (narrowing !== void 0) this.narrowingMemo.set(sessionId, narrowing);
841
+ }
842
+ /**
843
+ * Persist one un-recorded session's seed, at most once until it lands.
844
+ *
845
+ * Fire-and-forget on purpose: every caller is a synchronous decision path
846
+ * (a tool dispatch, a prompt assembly) that cannot await a whole-document
847
+ * write, and none of them may fail because the repair could not be stored —
848
+ * the answer that read already gave stands either way. A failed repair is
849
+ * logged and re-armed.
850
+ * @param sessionId - the session to repair.
851
+ * @param seed - builds the pair to persist; may throw on an unreadable document.
852
+ */
853
+ scheduleRepair(sessionId, seed) {
854
+ if (this.repairing.has(sessionId)) return;
855
+ this.repairing.add(sessionId);
856
+ Promise.resolve().then(async () => {
857
+ if (this.get(sessionId) !== void 0) return;
858
+ await this.set(sessionId, seed());
859
+ this.repairing.delete(sessionId);
860
+ }).catch((error) => {
861
+ this.repairing.delete(sessionId);
862
+ this.ctx.logger?.warn("dual-axis: could not store the axes of session \"%s\"; it stays on the default pair", sessionId);
863
+ this.ctx.logger?.warn(error);
864
+ });
865
+ }
866
+ /**
867
+ * Prune records whose session no longer exists.
868
+ *
869
+ * The deletion signal is the persistence layer itself: a session whose id is
870
+ * absent from `sessionPersistence.list()` has no stored log. `session/disposed`
871
+ * is NOT that signal — residency churn disposes a session whose file is still on
872
+ * disk, and dropping its axes there would silently reset a session that is about
873
+ * to be resumed.
874
+ * @returns the ids dropped, or `undefined` when the sweep cannot judge (no
875
+ * persistence service mounted, or a listing failed): an unjudgeable sweep
876
+ * removes nothing.
877
+ */
878
+ async sweep() {
879
+ const records = this.records();
880
+ const ids = Object.keys(records);
881
+ if (ids.length === 0) return {
882
+ removed: [],
883
+ before: 0
884
+ };
885
+ const persistence = this.ctx.get("sessionPersistence");
886
+ if (persistence === void 0) return void 0;
887
+ let live;
888
+ try {
889
+ const listed = await persistence.list();
890
+ live = new Set(listed.map((snapshot) => String(snapshot.header.id)));
891
+ } catch (error) {
892
+ this.ctx.logger?.warn("dual-axis: could not list stored sessions, keeping every axis record");
893
+ this.ctx.logger?.warn(error);
894
+ return;
895
+ }
896
+ const kept = {};
897
+ const removed = [];
898
+ for (const id of ids) {
899
+ const record = records[id];
900
+ if (record === void 0) continue;
901
+ if (live.has(id)) kept[id] = record;
902
+ else removed.push(id);
903
+ }
904
+ if (removed.length === 0) return {
905
+ removed: [],
906
+ before: ids.length
907
+ };
908
+ await this.replaceRecords(kept);
909
+ return {
910
+ removed,
911
+ before: ids.length
912
+ };
913
+ }
914
+ /**
915
+ * Replace the whole field, retrying while the revision fence refuses it.
916
+ * @param records - the complete next field.
917
+ * @throws {SessionAxesConflictError} when every attempt was refused.
918
+ */
919
+ async replaceRecords(records) {
920
+ const settings = this.ctx.get("settings");
921
+ const entryExists = this.descriptor() !== void 0;
922
+ if (settings === void 0 || !entryExists) throw new Error("dual-axis: no configurable plugin entry \"dual-axis-sessions\"; the session axis store cannot be written");
923
+ let conflict;
924
+ for (let attempt = 0; attempt < this.options.attempts; attempt += 1) {
925
+ const backoff = this.options.backoffMs[attempt] ?? this.options.backoffMs[this.options.backoffMs.length - 1] ?? 0;
926
+ if (backoff > 0) await this.options.sleep(backoff);
927
+ const revision = this.descriptor()?.revision;
928
+ try {
929
+ await settings.replace(SESSION_AXES_NAMESPACE, { [SESSION_AXES_FIELD]: records }, revision);
930
+ return;
931
+ } catch (error) {
932
+ conflict = error;
933
+ }
934
+ }
935
+ throw new SessionAxesConflictError(conflict, this.options.attempts);
936
+ }
937
+ };
938
+ /** Stores by host context, so every consumer of one process shares one cache. */
939
+ const stores = /* @__PURE__ */ new WeakMap();
940
+ /**
941
+ * The store of one host context. Consumers hold no service of their own: the
942
+ * fence and the prompt must keep working in a composition that never mounted the
943
+ * settings service, where the store simply has nothing to read.
944
+ * @param ctx - host context.
945
+ * @returns the shared store.
946
+ */
947
+ function sessionAxesStore(ctx) {
948
+ const existing = stores.get(ctx);
949
+ if (existing !== void 0) return existing;
950
+ const created = new SessionAxesStore(ctx);
951
+ stores.set(ctx, created);
952
+ return created;
953
+ }
954
+ /**
955
+ * This entry's plugin body.
956
+ *
957
+ * The entry exists for its Config: `SettingsForms.describe` projects the schemas
958
+ * of LOADED entries, so the session axis store has no namespace to live in until
959
+ * a Loader row activates with this plugin. The body itself does nothing — every
960
+ * read and write goes through {@link SessionAxesStore}, which the composing
961
+ * plugin owns.
962
+ * @returns nothing.
963
+ */
964
+ async function apply() {}
965
+ /**
966
+ * The Loader entry's value: the no-op apply carrying the Config the registry
967
+ * reads off the plugin value itself. Spelled as an interface for the same reason
968
+ * {@link Config} is annotated — the inferred type of `Object.assign(apply, {
969
+ * Config })` names `Dict` from `@deepseek-ai/cosmokit`, which declaration emit
970
+ * cannot reach by name.
971
+ */
972
+ const entry = Object.assign(apply, { Config });
973
+ Object.defineProperty(apply, "name", {
974
+ value: "dual-axis-sessions",
975
+ configurable: true
976
+ });
977
+ //#endregion
978
+ export { AXES_WRITE_ATTEMPTS, AXES_WRITE_BACKOFF_MS, Config, DEFAULT_AXES, SESSION_AXES_FIELD, SESSION_AXES_NAMESPACE, SessionAxesConflictError, SessionAxesStore, entry as default, inheritedAxes, parseSessionAxesField, parseSessionAxesRecord, seedAxesFor, seedPair, sessionAxesStore };