@klarkxy/dsh-plugin-kit 0.4.0 → 0.6.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,354 @@
1
+ import { AUXILIARY_MODEL_CHANGES_IDENTITY, AUXILIARY_MODEL_CHANNEL, AUXILIARY_MODEL_NS, AUXILIARY_MODEL_SERVICE, getAuxiliaryModel } from "./contracts.js";
2
+ import { registerHostRpc } from "./host-rpc.js";
3
+ import { notifyModelSettingsChanged, registerModelSettings } from "./model-settings.js";
4
+ import { createModelSettingsChanges } from "./model-settings-changes.js";
5
+ import { modelRouteOverride, normalizeModelRoute } from "@klarkxy/dsh-model-route";
6
+ //#region src/auxiliary-model.ts
7
+ const DOMAIN_NAME = "dsh_auxiliary";
8
+ const DOMAIN_VERSION = 1;
9
+ const TABLE = "settings";
10
+ const KEY = "defaults";
11
+ const INJECT = [
12
+ "storageDomain",
13
+ "webServer",
14
+ "connection"
15
+ ];
16
+ /** Marker on the benign "released while the domain was still opening" error. */
17
+ const RECOVERABLE = Symbol.for("klarkxy.dsh.auxiliary-model.recoverable");
18
+ function recoverable(error) {
19
+ error[RECOVERABLE] = true;
20
+ return error;
21
+ }
22
+ async function openSharedDomain(scope) {
23
+ const [{ defineDomain, domainTable }, { z }] = await Promise.all([import("@deepseek-ai/dsh-storage-domain"), import("./zod.js")]);
24
+ const identifier = z.string().min(1).max(250).refine((value) => value === value.trim() && !/[\x00-\x1f\x7f]/.test(value), "model route identifiers must be non-blank, trimmed and control-free");
25
+ const model = z.union([z.object({
26
+ provider: identifier,
27
+ model: identifier,
28
+ reasoningEffort: identifier.max(80).optional()
29
+ }).strict(), z.null()]);
30
+ return scope.storageDomain.open(defineDomain({
31
+ name: DOMAIN_NAME,
32
+ version: DOMAIN_VERSION,
33
+ tables: { [TABLE]: domainTable(z.object({
34
+ revision: z.number().int().nonnegative(),
35
+ model
36
+ }).strict()) }
37
+ }));
38
+ }
39
+ /** Marks an optimistic-lock conflict so the RPC envelope can carry 'conflict'. */
40
+ function conflictError(message) {
41
+ return Object.assign(new Error(message), { conflict: true });
42
+ }
43
+ /** RPC-visible error classification; storage failures stay sanitized. */
44
+ function rpcFailure(error) {
45
+ if (error && typeof error === "object" && "conflict" in error) return {
46
+ ok: false,
47
+ error: {
48
+ code: "auxiliary-model/conflict",
49
+ message: error instanceof Error ? error.message : "Revision conflict"
50
+ }
51
+ };
52
+ if (error instanceof Error && /Invalid model route|valid expected revision|Unknown auxiliary model setting/.test(error.message)) return {
53
+ ok: false,
54
+ error: {
55
+ code: "auxiliary-model/rejected",
56
+ message: error.message
57
+ }
58
+ };
59
+ return {
60
+ ok: false,
61
+ error: {
62
+ code: "auxiliary-model/store",
63
+ message: "Auxiliary model store request failed"
64
+ }
65
+ };
66
+ }
67
+ /**
68
+ * Serialized optimistic-lock write; resolves after the durable put commits.
69
+ * Revision verification happens inside the queue: two writers carrying the
70
+ * same expectedRevision race for one slot and only the first commits.
71
+ * Notification fires only after commit, so a failed observer can never turn
72
+ * an already committed write into an apparent save failure.
73
+ */
74
+ function setSharedModel(state, ctx, input, expectedRevision) {
75
+ if (!Number.isSafeInteger(expectedRevision)) return Promise.reject(conflictError("A valid expected revision is required; reload and retry"));
76
+ const row = input && typeof input === "object" && !Array.isArray(input) ? input : void 0;
77
+ const validField = (value) => typeof value === "string" && value === value.trim() && !/[\x00-\x1f\x7f]/.test(value);
78
+ if (!row || [
79
+ "provider",
80
+ "model",
81
+ "reasoningEffort"
82
+ ].some((key) => row[key] !== void 0 && !validField(row[key]))) return Promise.reject(/* @__PURE__ */ new Error("Invalid model route"));
83
+ const normalized = normalizeModelRoute(row);
84
+ if ((Boolean(row.provider) || Boolean(row.model)) && !modelRouteOverride(normalized)) return Promise.reject(/* @__PURE__ */ new Error("Invalid model route"));
85
+ const commit = state.tail.then(async () => {
86
+ if (state.closed || !state.domain) throw new Error("Auxiliary model settings are unloading; retry in a moment");
87
+ if (expectedRevision !== state.data.revision) throw conflictError("Auxiliary model settings changed; reload and retry");
88
+ const next = {
89
+ revision: state.data.revision + 1,
90
+ model: modelRouteOverride(normalized) ?? null
91
+ };
92
+ await state.domain.table(TABLE).put(KEY, {
93
+ revision: next.revision,
94
+ model: next.model
95
+ });
96
+ state.data = next;
97
+ notifyModelSettingsChanged(ctx.root, AUXILIARY_MODEL_NS, next.revision);
98
+ return next;
99
+ });
100
+ state.tail = commit.then(() => {}, () => {});
101
+ return commit;
102
+ }
103
+ function initScope(state, ctx, scope) {
104
+ const generation = ++state.generation;
105
+ let disposed = false;
106
+ scope.effect(() => () => {
107
+ disposed = true;
108
+ });
109
+ const stale = () => state.closed || disposed || state.generation !== generation;
110
+ const run = (async () => {
111
+ state.available = false;
112
+ const domain = await openSharedDomain(scope);
113
+ if (stale()) {
114
+ await domain.close();
115
+ throw recoverable(/* @__PURE__ */ new Error("Auxiliary model store released during open"));
116
+ }
117
+ state.domain = domain;
118
+ try {
119
+ scope.effect(() => () => domain.close());
120
+ } catch (error) {
121
+ await domain.close();
122
+ throw error;
123
+ }
124
+ const stored = domain.table(TABLE).get(KEY);
125
+ if (stale()) {
126
+ await domain.close();
127
+ throw recoverable(/* @__PURE__ */ new Error("Auxiliary model store superseded during open"));
128
+ }
129
+ state.data = {
130
+ revision: stored?.revision ?? 0,
131
+ model: stored?.model ?? null
132
+ };
133
+ state.error = void 0;
134
+ state.available = true;
135
+ scope.effect(() => registerModelSettings(scope, {
136
+ ns: AUXILIARY_MODEL_NS,
137
+ title: {
138
+ zh: "辅助模型",
139
+ en: "Auxiliary model"
140
+ },
141
+ read(signal) {
142
+ signal.throwIfAborted();
143
+ const { revision, model } = state.data;
144
+ return {
145
+ revision,
146
+ fields: [{
147
+ path: ["model"],
148
+ label: {
149
+ zh: "全局辅助模型",
150
+ en: "Global auxiliary model"
151
+ },
152
+ current: model ?? void 0,
153
+ allowFollow: true,
154
+ followLabel: {
155
+ zh: "当前会话模型",
156
+ en: "Current conversation model"
157
+ }
158
+ }]
159
+ };
160
+ },
161
+ async write(path, route, expectedRevision, signal) {
162
+ signal.throwIfAborted();
163
+ if (path.length !== 1 || path[0] !== "model") throw new Error("Unknown auxiliary model setting");
164
+ return setSharedModel(state, ctx, route, expectedRevision);
165
+ }
166
+ }));
167
+ scope.effect(() => registerHostRpc(scope, AUXILIARY_MODEL_CHANNEL, async (endpoint, payload, signal) => {
168
+ signal.throwIfAborted();
169
+ if (state.closed) return {
170
+ ok: false,
171
+ error: {
172
+ code: "auxiliary-model/unloading",
173
+ message: "Auxiliary model settings are unloading; retry in a moment"
174
+ }
175
+ };
176
+ if (!state.available) return state.error ? {
177
+ ok: false,
178
+ error: {
179
+ code: "auxiliary-model/store",
180
+ message: "Auxiliary model store request failed"
181
+ }
182
+ } : {
183
+ ok: false,
184
+ error: {
185
+ code: "auxiliary-model/unavailable",
186
+ message: "Auxiliary model settings are unavailable; retry in a moment"
187
+ }
188
+ };
189
+ if (endpoint === "defaults.get") {
190
+ const { revision, model } = state.data;
191
+ return {
192
+ ok: true,
193
+ value: {
194
+ revision,
195
+ model
196
+ }
197
+ };
198
+ }
199
+ if (endpoint === "defaults.set") try {
200
+ const body = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
201
+ const value = await setSharedModel(state, ctx, body.model, body.expectedRevision);
202
+ signal.throwIfAborted();
203
+ return {
204
+ ok: true,
205
+ value
206
+ };
207
+ } catch (error) {
208
+ if (signal.aborted) throw error;
209
+ return rpcFailure(error);
210
+ }
211
+ return {
212
+ ok: false,
213
+ error: {
214
+ code: "auxiliary-model/unknown-endpoint",
215
+ message: `Unknown endpoint: ${endpoint}`
216
+ }
217
+ };
218
+ }, { isEndpoint: (endpoint) => /^[a-z.]+$/.test(endpoint) }));
219
+ createModelSettingsChanges(scope, AUXILIARY_MODEL_CHANGES_IDENTITY, AUXILIARY_MODEL_NS);
220
+ scope.effect(() => () => {
221
+ if (state.generation === generation) {
222
+ state.available = false;
223
+ state.domain = void 0;
224
+ }
225
+ });
226
+ })().catch((error) => {
227
+ if (error && typeof error === "object") error.auxGeneration = generation;
228
+ if (state.generation === generation) {
229
+ state.available = false;
230
+ state.error = error;
231
+ }
232
+ throw error;
233
+ });
234
+ run.catch(() => {});
235
+ const armed = run.then(() => {});
236
+ armed.catch(() => {});
237
+ state.ready = armed;
238
+ return run;
239
+ }
240
+ function teardown(state, ctx) {
241
+ if (!state.closing) {
242
+ state.closed = true;
243
+ const root = ctx.root;
244
+ state.closing = (async () => {
245
+ await state.scopeInit?.catch(() => {});
246
+ await state.tail.catch(() => {});
247
+ try {
248
+ await state.fiber?.dispose();
249
+ } catch (error) {
250
+ try {
251
+ ctx.logger?.warn("auxiliary-model: fiber dispose failed", error);
252
+ } catch {}
253
+ }
254
+ try {
255
+ if (root.get("dshAuxiliaryModel", false) === state) state.stopUnprovide?.();
256
+ } catch {}
257
+ state.stopUnprovide = void 0;
258
+ })();
259
+ }
260
+ return state.closing;
261
+ }
262
+ function createSharedState(ctx) {
263
+ const root = ctx.root;
264
+ const state = {
265
+ users: 0,
266
+ closed: false,
267
+ available: false,
268
+ generation: 0,
269
+ tail: Promise.resolve(),
270
+ data: {
271
+ revision: 0,
272
+ model: null
273
+ },
274
+ snapshot() {
275
+ if (this.error) throw this.error;
276
+ if (!this.available) throw new Error("Auxiliary model store is unavailable; retry in a moment");
277
+ return this.data;
278
+ },
279
+ ready: Promise.resolve(),
280
+ teardown() {
281
+ return teardown(this, ctx);
282
+ }
283
+ };
284
+ const inactive = INJECT.filter((service) => {
285
+ try {
286
+ return root.get(service, true) === void 0;
287
+ } catch {
288
+ return true;
289
+ }
290
+ });
291
+ if (inactive.length) throw new Error(`retainAuxiliaryModel requires active ${inactive.join(", ")}; declare them in inject`);
292
+ state.fiber = root.inject(INJECT, (scope) => {
293
+ state.scopeInit = initScope(state, ctx, scope);
294
+ });
295
+ state.ready = Promise.resolve(state.fiber).then(() => {
296
+ if (!state.scopeInit) throw recoverable(/* @__PURE__ */ new Error("Auxiliary model owner fiber settled without initializing its scope"));
297
+ return state.scopeInit;
298
+ });
299
+ state.ready.catch((error) => {
300
+ const tag = error.auxGeneration;
301
+ if (tag !== void 0 && tag !== state.generation) return;
302
+ state.error = state.error ?? error;
303
+ state.closed = true;
304
+ state.teardown();
305
+ });
306
+ state.stopUnprovide = root.provide(AUXILIARY_MODEL_SERVICE, state);
307
+ return state;
308
+ }
309
+ async function obtainShared(ctx) {
310
+ const root = ctx.root;
311
+ for (;;) {
312
+ const existing = root.get(AUXILIARY_MODEL_SERVICE, false);
313
+ if (existing) {
314
+ if (!existing.closed) return existing;
315
+ await existing.closing;
316
+ continue;
317
+ }
318
+ return createSharedState(ctx);
319
+ }
320
+ }
321
+ /**
322
+ * Retain the shared auxiliary-model store for the calling plugin.
323
+ *
324
+ * Resolves once storage is open and the read/write surfaces are live, with a
325
+ * release function; register it as `ctx.effect(() => release)` so the store
326
+ * unloads with the plugin. The first participant opens the shared owner
327
+ * fiber; `await release()` drains pending writes and fully tears the owner
328
+ * down when the last participant leaves. Requires `storageDomain`,
329
+ * `webServer` and `connection` to exist on the host.
330
+ *
331
+ * Concurrent retainers share one owner; a retain arriving during teardown
332
+ * waits for the closing owner and then reinstalls — it never inherits a
333
+ * half-torn-down state.
334
+ */
335
+ async function retainAuxiliaryModel(ctx) {
336
+ for (;;) {
337
+ const state = await obtainShared(ctx);
338
+ await state.ready;
339
+ if (state.closed) continue;
340
+ state.users += 1;
341
+ let released = false;
342
+ return async () => {
343
+ if (released) return;
344
+ released = true;
345
+ state.users -= 1;
346
+ if (state.users <= 0) await state.teardown();
347
+ else await state.tail.catch(() => {});
348
+ };
349
+ }
350
+ }
351
+ //#endregion
352
+ export { getAuxiliaryModel, retainAuxiliaryModel };
353
+
354
+ //# sourceMappingURL=auxiliary-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auxiliary-model.js","names":[],"sources":["../src/auxiliary-model.ts"],"sourcesContent":["/**\n * Shared auxiliary-model default: host-side lifecycle.\n *\n * The default model is repository-shared infrastructure, not one plugin's\n * business state. Its owner is a dedicated Cordis fiber started on the root\n * context by the first participant and disposed by the last release, so the\n * storage domain, the Typert change stream, the RPC channel and the\n * model-settings provider all live and die with the shared owner — never\n * with a business plugin's fiber. Every bundler's own copy of this module\n * rendezvous through `ctx.root`, so no participant depends on another being\n * enabled.\n *\n * Concurrency:\n * - Writes serialize on `state.tail`; the revision check and the bump both\n * happen inside the queued commit, so two callers carrying the same\n * expectedRevision cannot both succeed.\n * - `state.closing` is the single teardown promise: it settles init, drains\n * queued writes, disposes the owner fiber (route, stream, provider,\n * domain) and only then unprovides the state, so a re-entrant retain can\n * always await it and reinstall cleanly.\n * - Initialization and stored-row failures are sticky (`state.error`) and\n * rethrow from `snapshot()`: a storage error must never read as \"no\n * default\" and silently fall back to another model.\n */\nimport type { Context } from '@deepseek-ai/cordis'\nimport { modelRouteOverride, normalizeModelRoute, type ModelRoute } from '@klarkxy/dsh-model-route'\nimport {\n AUXILIARY_MODEL_CHANNEL, AUXILIARY_MODEL_CHANGES_IDENTITY, AUXILIARY_MODEL_NS,\n AUXILIARY_MODEL_SERVICE, type AuxiliaryModelDefaults, type AuxiliaryModelShared, type RpcResult,\n} from './contracts.ts'\nimport { registerHostRpc, type HostRpcContext } from './host-rpc.ts'\nimport { createModelSettingsChanges } from './model-settings-changes.ts'\nimport { registerModelSettings, notifyModelSettingsChanged, type ModelSettingsEvents } from './model-settings.ts'\n\nconst DOMAIN_NAME = 'dsh_auxiliary'\nconst DOMAIN_VERSION = 1\nconst TABLE = 'settings'\nconst KEY = 'defaults'\nconst INJECT = ['storageDomain', 'webServer', 'connection'] as const\n\ninterface DomainHandle {\n table(name: string): {\n get(key: string): { revision: number; model: ModelRoute | null } | undefined\n put(key: string, value: unknown): Promise<void>\n }\n close(): Promise<void>\n}\n\ninterface Scope extends HostRpcContext, ModelSettingsEvents {\n storageDomain: { open(spec: unknown): Promise<DomainHandle> }\n effect(callback: () => (() => void) | Promise<(() => void) | void>): unknown\n}\n\ninterface RootLike {\n get(name: string, strict?: boolean): unknown\n provide(name: string, value: unknown): () => void\n inject(deps: readonly string[], callback: (scope: Scope) => void): { dispose(): Promise<void> } & PromiseLike<unknown>\n}\n\ninterface SharedState extends AuxiliaryModelShared {\n ready: Promise<void>\n users: number\n closed: boolean\n /** True only while the owner fiber's domain is open; snapshot() gates on it. */\n available: boolean\n /** Generation of the latest scope init; a stale run must not win. */\n generation: number\n /** Sticky init/storage failure; snapshot() rethrows it. */\n error?: unknown\n closing?: Promise<void>\n domain?: DomainHandle\n data: AuxiliaryModelDefaults\n tail: Promise<void>\n fiber?: { dispose(): Promise<void> }\n scopeInit?: Promise<void>\n stopUnprovide?: () => void\n teardown(): Promise<void>\n}\n\n/** Marker on the benign \"released while the domain was still opening\" error. */\nconst RECOVERABLE = Symbol.for('klarkxy.dsh.auxiliary-model.recoverable')\nfunction recoverable<T extends object>(error: T): T & { [RECOVERABLE]?: boolean } {\n (error as Record<symbol, unknown>)[RECOVERABLE] = true\n return error as T & { [RECOVERABLE]?: boolean }\n}\n\nasync function openSharedDomain(scope: Scope): Promise<DomainHandle> {\n const [{ defineDomain, domainTable }, { z }] = await Promise.all([\n import('@deepseek-ai/dsh-storage-domain'), import('zod'),\n ])\n // Strict, write-side semantics at the durable boundary: identifiers must be\n // non-blank, already trimmed and control-free, or the row is rejected —\n // a malformed stored route must never normalize into \"no default\".\n const identifier = z.string().min(1).max(250).refine(\n value => value === value.trim() && !/[\\x00-\\x1f\\x7f]/.test(value),\n 'model route identifiers must be non-blank, trimmed and control-free',\n )\n const model = z.union([\n z.object({\n provider: identifier,\n model: identifier,\n reasoningEffort: identifier.max(80).optional(),\n }).strict(),\n z.null(),\n ])\n return scope.storageDomain.open(defineDomain({\n name: DOMAIN_NAME, version: DOMAIN_VERSION,\n tables: {\n [TABLE]: domainTable(z.object({\n revision: z.number().int().nonnegative(),\n model,\n }).strict()),\n },\n }))\n}\n\n/** Marks an optimistic-lock conflict so the RPC envelope can carry 'conflict'. */\nfunction conflictError(message: string): Error & { conflict: true } {\n return Object.assign(new Error(message), { conflict: true as const })\n}\n\n/** RPC-visible error classification; storage failures stay sanitized. */\nfunction rpcFailure(error: unknown): RpcResult {\n if (error && typeof error === 'object' && 'conflict' in error) {\n return { ok: false, error: { code: 'auxiliary-model/conflict', message: error instanceof Error ? error.message : 'Revision conflict' } }\n }\n if (error instanceof Error && /Invalid model route|valid expected revision|Unknown auxiliary model setting/.test(error.message)) {\n return { ok: false, error: { code: 'auxiliary-model/rejected', message: error.message } }\n }\n return { ok: false, error: { code: 'auxiliary-model/store', message: 'Auxiliary model store request failed' } }\n}\n\n/**\n * Serialized optimistic-lock write; resolves after the durable put commits.\n * Revision verification happens inside the queue: two writers carrying the\n * same expectedRevision race for one slot and only the first commits.\n * Notification fires only after commit, so a failed observer can never turn\n * an already committed write into an apparent save failure.\n */\nfunction setSharedModel(state: SharedState, ctx: Context, input: unknown, expectedRevision: unknown): Promise<AuxiliaryModelDefaults> {\n if (!Number.isSafeInteger(expectedRevision)) return Promise.reject(conflictError('A valid expected revision is required; reload and retry'))\n const row = input && typeof input === 'object' && !Array.isArray(input) ? input as Record<string, unknown> : undefined\n const validField = (value: unknown): value is string =>\n typeof value === 'string' && value === value.trim() && !/[\\x00-\\x1f\\x7f]/.test(value)\n if (!row || ['provider', 'model', 'reasoningEffort'].some(key => row[key] !== undefined && !validField(row[key]))) {\n return Promise.reject(new Error('Invalid model route'))\n }\n const normalized = normalizeModelRoute(row)\n if ((Boolean(row.provider) || Boolean(row.model)) && !modelRouteOverride(normalized)) {\n return Promise.reject(new Error('Invalid model route'))\n }\n const commit = state.tail.then(async (): Promise<AuxiliaryModelDefaults> => {\n if (state.closed || !state.domain) throw new Error('Auxiliary model settings are unloading; retry in a moment')\n if (expectedRevision !== state.data.revision) throw conflictError('Auxiliary model settings changed; reload and retry')\n const next: AuxiliaryModelDefaults = { revision: state.data.revision + 1, model: modelRouteOverride(normalized) ?? null }\n await state.domain.table(TABLE).put(KEY, { revision: next.revision, model: next.model })\n state.data = next\n notifyModelSettingsChanged(ctx.root, AUXILIARY_MODEL_NS, next.revision)\n return next\n })\n // The tail itself never rejects: a failed commit must not poison later writes.\n state.tail = commit.then(() => {}, () => {})\n return commit\n}\n\nfunction initScope(state: SharedState, ctx: Context, scope: Scope): Promise<void> {\n // A dependency loss unloads this fiber (running its disposers) and a\n // remount re-runs the callback (bumping state.generation), so an init that\n // was still opening always detects its own supersession through these two\n // public signals. Service identity is intentionally NOT compared anywhere:\n // `ctx.<service>` reads hand out a fresh traceable proxy per access for\n // every tracker'd impl (all Service subclasses — storageDomain, webServer,\n // connection), so identity comparisons reported a fake dep swap after each\n // await and aborted every open with 'released during open'.\n const generation = ++state.generation\n let disposed = false\n // Registered synchronously while the callback runs; a throw means the\n // fiber is already inactive and this init must not open anything.\n scope.effect(() => () => { disposed = true })\n const stale = () => state.closed || disposed || state.generation !== generation\n const run = (async () => {\n // Reads must stay unavailable until this init completes.\n state.available = false\n const domain = await openSharedDomain(scope)\n if (stale()) { await domain.close(); throw recoverable(new Error('Auxiliary model store released during open')) }\n state.domain = domain\n // scope.effect throws on an inactive fiber (disposed mid-open): close the\n // domain directly so it is never leaked, then rethrow — a store that\n // cannot register its own cleanup must not publish data.\n try { scope.effect(() => () => domain.close()) } catch (error) { await domain.close(); throw error }\n // Strict schema: a malformed stored row fails the open path instead of\n // silently collapsing to \"no default\".\n const stored = domain.table(TABLE).get(KEY)\n if (stale()) { await domain.close(); throw recoverable(new Error('Auxiliary model store superseded during open')) }\n state.data = { revision: stored?.revision ?? 0, model: stored?.model ?? null }\n state.error = undefined\n state.available = true\n scope.effect(() => registerModelSettings(scope, {\n ns: AUXILIARY_MODEL_NS,\n title: { zh: '辅助模型', en: 'Auxiliary model' },\n read(signal) {\n signal.throwIfAborted()\n const { revision, model } = state.data\n return {\n revision,\n fields: [{\n path: ['model'],\n label: { zh: '全局辅助模型', en: 'Global auxiliary model' },\n current: model ?? undefined,\n allowFollow: true,\n followLabel: { zh: '当前会话模型', en: 'Current conversation model' },\n }],\n }\n },\n async write(path, route, expectedRevision, signal) {\n signal.throwIfAborted()\n if (path.length !== 1 || path[0] !== 'model') throw new Error('Unknown auxiliary model setting')\n return setSharedModel(state, ctx, route, expectedRevision)\n },\n }))\n scope.effect(() => registerHostRpc(scope, AUXILIARY_MODEL_CHANNEL, async (endpoint, payload, signal) => {\n signal.throwIfAborted()\n if (state.closed) return { ok: false, error: { code: 'auxiliary-model/unloading', message: 'Auxiliary model settings are unloading; retry in a moment' } } satisfies RpcResult\n if (!state.available) {\n return state.error\n ? { ok: false, error: { code: 'auxiliary-model/store', message: 'Auxiliary model store request failed' } } satisfies RpcResult\n : { ok: false, error: { code: 'auxiliary-model/unavailable', message: 'Auxiliary model settings are unavailable; retry in a moment' } } satisfies RpcResult\n }\n if (endpoint === 'defaults.get') {\n const { revision, model } = state.data\n return { ok: true, value: { revision, model } } satisfies RpcResult\n }\n if (endpoint === 'defaults.set') {\n try {\n const body = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload as Record<string, unknown> : {}\n const value = await setSharedModel(state, ctx, body.model, body.expectedRevision)\n signal.throwIfAborted()\n return { ok: true, value } satisfies RpcResult\n } catch (error) {\n if (signal.aborted) throw error\n return rpcFailure(error)\n }\n }\n return { ok: false, error: { code: 'auxiliary-model/unknown-endpoint', message: `Unknown endpoint: ${endpoint}` } } satisfies RpcResult\n }, { isEndpoint: endpoint => /^[a-z.]+$/.test(endpoint) }))\n // The change stream lives and dies with this shared fiber.\n createModelSettingsChanges(scope as unknown as Context, AUXILIARY_MODEL_CHANGES_IDENTITY, AUXILIARY_MODEL_NS)\n // Disposers run LIFO on unload: registered last, this runs FIRST when a\n // dependency remount tears the scope down — reads go unavailable before\n // the domain closes, so no stale default ever leaks. Gated on generation\n // alone: the same generation's unload must clear state even when its dep\n // impl just changed; only a superseded scope's teardown stays out.\n scope.effect(() => () => { if (state.generation === generation) { state.available = false; state.domain = undefined } })\n })().catch(error => {\n // Tag the failure with its generation so ready.catch can distinguish a\n // superseded init's rejection from the live scope's.\n if (error && typeof error === 'object') (error as Record<string, unknown>).auxGeneration = generation\n // Only THIS generation's failure poisons the store: a superseded init's\n // rejection must not clear a newer scope's available state.\n if (state.generation === generation) {\n state.available = false\n state.error = error\n }\n throw error\n })\n // Retainers always await the CURRENT init: re-arm state.ready so nobody is\n // left waiting on a superseded generation's settled promise. Both promises\n // get a handled branch so a rejected init never surfaces as an unhandled\n // rejection while awaiters still see it.\n run.catch(() => {})\n const armed = run.then(() => {})\n armed.catch(() => {})\n state.ready = armed\n return run\n}\n\nfunction teardown(state: SharedState, ctx: Context): Promise<void> {\n if (!state.closing) {\n state.closed = true\n const root = ctx.root as unknown as RootLike\n state.closing = (async () => {\n // A still-opening init settles here so its domain/fiber are disposed.\n await state.scopeInit?.catch(() => {})\n await state.tail.catch(() => {})\n try { await state.fiber?.dispose() } catch (error) {\n // A failed dispose may leave the domain open; log loudly so the next\n // open's 'already open' error is attributable instead of mysterious.\n try { (ctx as unknown as { logger?: { warn(...args: unknown[]): void } }).logger?.warn('auxiliary-model: fiber dispose failed', error) }\n catch { /* no logger */ }\n }\n try {\n if (root.get(AUXILIARY_MODEL_SERVICE, false) === state) state.stopUnprovide?.()\n } catch { /* root already gone */ }\n state.stopUnprovide = undefined\n })()\n }\n return state.closing\n}\n\nfunction createSharedState(ctx: Context): SharedState {\n const root = ctx.root as unknown as RootLike\n const state: SharedState = {\n users: 0, closed: false, available: false, generation: 0, tail: Promise.resolve(),\n data: { revision: 0, model: null },\n snapshot() {\n // A persisted-open failure propagates: falling back to another model\n // after a storage error would silently switch what the user pays for.\n if (this.error) throw this.error\n // While the owner fiber re-initializes after a dependency remount, the\n // stored default is unreadable: reads fail instead of serving stale data.\n if (!this.available) throw new Error('Auxiliary model store is unavailable; retry in a moment')\n return this.data\n },\n ready: Promise.resolve(),\n teardown() { return teardown(this, ctx) },\n }\n // Strict requirement: every service must already be ACTIVE. Callers are\n // plugins declaring these in inject — Cordis only runs their apply once the\n // deps are active, so a missing or still-pending service is a hard error,\n // not something to wait on. Retry is the caller's Cordis lifecycle, not ours.\n const inactive = INJECT.filter(service => {\n try { return root.get(service, true) === undefined } catch { return true }\n })\n if (inactive.length) {\n throw new Error(`retainAuxiliaryModel requires active ${inactive.join(', ')}; declare them in inject`)\n }\n state.fiber = root.inject(INJECT, scope => { state.scopeInit = initScope(state, ctx, scope) })\n state.ready = Promise.resolve(state.fiber).then(() => {\n // The owner fiber ran to settle: its scope must have started init, and\n // that init must complete before the store reads as ready. A dep dropping\n // between the strict check and inject leaves the fiber PENDING; Cordis\n // re-runs it when the dep returns — never a silent half-initialized read.\n if (!state.scopeInit) throw recoverable(new Error('Auxiliary model owner fiber settled without initializing its scope'))\n return state.scopeInit\n })\n // A failed open must not leave a live-looking state behind: mark it\n // closed and tear it down so the next retain reinstalls. A rejection\n // tagged with a superseded generation describes a scope this state has\n // already moved past — it must not tear down a newer, live scope.\n state.ready.catch(error => {\n const tag = (error as { auxGeneration?: number }).auxGeneration\n if (tag !== undefined && tag !== state.generation) return\n state.error = state.error ?? error\n state.closed = true\n void state.teardown()\n })\n // Publish only after construction completes: another retainer can now\n // either reuse this state or watch its closed flag.\n state.stopUnprovide = root.provide(AUXILIARY_MODEL_SERVICE, state)\n return state\n}\n\nasync function obtainShared(ctx: Context): Promise<SharedState> {\n const root = ctx.root as unknown as RootLike\n for (;;) {\n const existing = root.get(AUXILIARY_MODEL_SERVICE, false) as SharedState | undefined\n if (existing) {\n if (!existing.closed) return existing\n // closing always settles; wait for it, then re-lookup.\n await existing.closing\n continue\n }\n return createSharedState(ctx)\n }\n}\n\n/**\n * Retain the shared auxiliary-model store for the calling plugin.\n *\n * Resolves once storage is open and the read/write surfaces are live, with a\n * release function; register it as `ctx.effect(() => release)` so the store\n * unloads with the plugin. The first participant opens the shared owner\n * fiber; `await release()` drains pending writes and fully tears the owner\n * down when the last participant leaves. Requires `storageDomain`,\n * `webServer` and `connection` to exist on the host.\n *\n * Concurrent retainers share one owner; a retain arriving during teardown\n * waits for the closing owner and then reinstalls — it never inherits a\n * half-torn-down state.\n */\nexport async function retainAuxiliaryModel(ctx: Context): Promise<() => Promise<void>> {\n for (;;) {\n const state = await obtainShared(ctx)\n // One await, one answer: a failed or superseded init rejects the retainer\n // once. The caller's plugin lifecycle (inject refresh) drives any retry —\n // no same-state spin here.\n await state.ready\n if (state.closed) continue\n state.users += 1\n let released = false\n return async () => {\n if (released) return\n released = true\n state.users -= 1\n if (state.users <= 0) await state.teardown()\n else await state.tail.catch(() => {})\n }\n }\n}\n\n/**\n * Re-exported here so consumers reading the shared default use the same\n * subpath as the store lifecycle: '@klarkxy/dsh-plugin-kit/auxiliary-model'.\n */\nexport { getAuxiliaryModel } from './contracts.ts'\n"],"mappings":";;;;;;AAkCA,MAAM,cAAc;AACpB,MAAM,iBAAiB;AACvB,MAAM,QAAQ;AACd,MAAM,MAAM;AACZ,MAAM,SAAS;CAAC;CAAiB;CAAa;AAAY;;AA0C1D,MAAM,cAAc,OAAO,IAAI,yCAAyC;AACxE,SAAS,YAA8B,OAA2C;CAChF,MAAmC,eAAe;CAClD,OAAO;AACT;AAEA,eAAe,iBAAiB,OAAqC;CACnE,MAAM,CAAC,EAAE,cAAc,eAAe,EAAE,OAAO,MAAM,QAAQ,IAAI,CAC/D,OAAO,oCAAoC,OAAO,WACpD,CAAC;CAID,MAAM,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,QAC5C,UAAS,UAAU,MAAM,KAAK,KAAK,CAAC,kBAAkB,KAAK,KAAK,GAChE,qEACF;CACA,MAAM,QAAQ,EAAE,MAAM,CACpB,EAAE,OAAO;EACP,UAAU;EACV,OAAO;EACP,iBAAiB,WAAW,IAAI,EAAE,CAAC,CAAC,SAAS;CAC/C,CAAC,CAAC,CAAC,OAAO,GACV,EAAE,KAAK,CACT,CAAC;CACD,OAAO,MAAM,cAAc,KAAK,aAAa;EAC3C,MAAM;EAAa,SAAS;EAC5B,QAAQ,GACL,QAAQ,YAAY,EAAE,OAAO;GAC5B,UAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,YAAY;GACvC;EACF,CAAC,CAAC,CAAC,OAAO,CAAC,EACb;CACF,CAAC,CAAC;AACJ;;AAGA,SAAS,cAAc,SAA6C;CAClE,OAAO,OAAO,OAAO,IAAI,MAAM,OAAO,GAAG,EAAE,UAAU,KAAc,CAAC;AACtE;;AAGA,SAAS,WAAW,OAA2B;CAC7C,IAAI,SAAS,OAAO,UAAU,YAAY,cAAc,OACtD,OAAO;EAAE,IAAI;EAAO,OAAO;GAAE,MAAM;GAA4B,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAoB;CAAE;CAEzI,IAAI,iBAAiB,SAAS,8EAA8E,KAAK,MAAM,OAAO,GAC5H,OAAO;EAAE,IAAI;EAAO,OAAO;GAAE,MAAM;GAA4B,SAAS,MAAM;EAAQ;CAAE;CAE1F,OAAO;EAAE,IAAI;EAAO,OAAO;GAAE,MAAM;GAAyB,SAAS;EAAuC;CAAE;AAChH;;;;;;;;AASA,SAAS,eAAe,OAAoB,KAAc,OAAgB,kBAA4D;CACpI,IAAI,CAAC,OAAO,cAAc,gBAAgB,GAAG,OAAO,QAAQ,OAAO,cAAc,yDAAyD,CAAC;CAC3I,MAAM,MAAM,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,IAAI,QAAmC,KAAA;CAC7G,MAAM,cAAc,UAClB,OAAO,UAAU,YAAY,UAAU,MAAM,KAAK,KAAK,CAAC,kBAAkB,KAAK,KAAK;CACtF,IAAI,CAAC,OAAO;EAAC;EAAY;EAAS;CAAiB,CAAC,CAAC,MAAK,QAAO,IAAI,SAAS,KAAA,KAAa,CAAC,WAAW,IAAI,IAAI,CAAC,GAC9G,OAAO,QAAQ,uBAAO,IAAI,MAAM,qBAAqB,CAAC;CAExD,MAAM,aAAa,oBAAoB,GAAG;CAC1C,KAAK,QAAQ,IAAI,QAAQ,KAAK,QAAQ,IAAI,KAAK,MAAM,CAAC,mBAAmB,UAAU,GACjF,OAAO,QAAQ,uBAAO,IAAI,MAAM,qBAAqB,CAAC;CAExD,MAAM,SAAS,MAAM,KAAK,KAAK,YAA6C;EAC1E,IAAI,MAAM,UAAU,CAAC,MAAM,QAAQ,MAAM,IAAI,MAAM,2DAA2D;EAC9G,IAAI,qBAAqB,MAAM,KAAK,UAAU,MAAM,cAAc,oDAAoD;EACtH,MAAM,OAA+B;GAAE,UAAU,MAAM,KAAK,WAAW;GAAG,OAAO,mBAAmB,UAAU,KAAK;EAAK;EACxH,MAAM,MAAM,OAAO,MAAM,KAAK,CAAC,CAAC,IAAI,KAAK;GAAE,UAAU,KAAK;GAAU,OAAO,KAAK;EAAM,CAAC;EACvF,MAAM,OAAO;EACb,2BAA2B,IAAI,MAAM,oBAAoB,KAAK,QAAQ;EACtE,OAAO;CACT,CAAC;CAED,MAAM,OAAO,OAAO,WAAW,CAAC,SAAS,CAAC,CAAC;CAC3C,OAAO;AACT;AAEA,SAAS,UAAU,OAAoB,KAAc,OAA6B;CAShF,MAAM,aAAa,EAAE,MAAM;CAC3B,IAAI,WAAW;CAGf,MAAM,mBAAmB;EAAE,WAAW;CAAK,CAAC;CAC5C,MAAM,cAAc,MAAM,UAAU,YAAY,MAAM,eAAe;CACrE,MAAM,OAAO,YAAY;EAEvB,MAAM,YAAY;EAClB,MAAM,SAAS,MAAM,iBAAiB,KAAK;EAC3C,IAAI,MAAM,GAAG;GAAE,MAAM,OAAO,MAAM;GAAG,MAAM,4BAAY,IAAI,MAAM,4CAA4C,CAAC;EAAE;EAChH,MAAM,SAAS;EAIf,IAAI;GAAE,MAAM,mBAAmB,OAAO,MAAM,CAAC;EAAE,SAAS,OAAO;GAAE,MAAM,OAAO,MAAM;GAAG,MAAM;EAAM;EAGnG,MAAM,SAAS,OAAO,MAAM,KAAK,CAAC,CAAC,IAAI,GAAG;EAC1C,IAAI,MAAM,GAAG;GAAE,MAAM,OAAO,MAAM;GAAG,MAAM,4BAAY,IAAI,MAAM,8CAA8C,CAAC;EAAE;EAClH,MAAM,OAAO;GAAE,UAAU,QAAQ,YAAY;GAAG,OAAO,QAAQ,SAAS;EAAK;EAC7E,MAAM,QAAQ,KAAA;EACd,MAAM,YAAY;EAClB,MAAM,aAAa,sBAAsB,OAAO;GAC9C,IAAI;GACJ,OAAO;IAAE,IAAI;IAAQ,IAAI;GAAkB;GAC3C,KAAK,QAAQ;IACX,OAAO,eAAe;IACtB,MAAM,EAAE,UAAU,UAAU,MAAM;IAClC,OAAO;KACL;KACA,QAAQ,CAAC;MACP,MAAM,CAAC,OAAO;MACd,OAAO;OAAE,IAAI;OAAU,IAAI;MAAyB;MACpD,SAAS,SAAS,KAAA;MAClB,aAAa;MACb,aAAa;OAAE,IAAI;OAAU,IAAI;MAA6B;KAChE,CAAC;IACH;GACF;GACA,MAAM,MAAM,MAAM,OAAO,kBAAkB,QAAQ;IACjD,OAAO,eAAe;IACtB,IAAI,KAAK,WAAW,KAAK,KAAK,OAAO,SAAS,MAAM,IAAI,MAAM,iCAAiC;IAC/F,OAAO,eAAe,OAAO,KAAK,OAAO,gBAAgB;GAC3D;EACF,CAAC,CAAC;EACF,MAAM,aAAa,gBAAgB,OAAO,yBAAyB,OAAO,UAAU,SAAS,WAAW;GACtG,OAAO,eAAe;GACtB,IAAI,MAAM,QAAQ,OAAO;IAAE,IAAI;IAAO,OAAO;KAAE,MAAM;KAA6B,SAAS;IAA4D;GAAE;GACzJ,IAAI,CAAC,MAAM,WACT,OAAO,MAAM,QACT;IAAE,IAAI;IAAO,OAAO;KAAE,MAAM;KAAyB,SAAS;IAAuC;GAAE,IACvG;IAAE,IAAI;IAAO,OAAO;KAAE,MAAM;KAA+B,SAAS;IAA8D;GAAE;GAE1I,IAAI,aAAa,gBAAgB;IAC/B,MAAM,EAAE,UAAU,UAAU,MAAM;IAClC,OAAO;KAAE,IAAI;KAAM,OAAO;MAAE;MAAU;KAAM;IAAE;GAChD;GACA,IAAI,aAAa,gBACf,IAAI;IACF,MAAM,OAAO,WAAW,OAAO,YAAY,YAAY,CAAC,MAAM,QAAQ,OAAO,IAAI,UAAqC,CAAC;IACvH,MAAM,QAAQ,MAAM,eAAe,OAAO,KAAK,KAAK,OAAO,KAAK,gBAAgB;IAChF,OAAO,eAAe;IACtB,OAAO;KAAE,IAAI;KAAM;IAAM;GAC3B,SAAS,OAAO;IACd,IAAI,OAAO,SAAS,MAAM;IAC1B,OAAO,WAAW,KAAK;GACzB;GAEF,OAAO;IAAE,IAAI;IAAO,OAAO;KAAE,MAAM;KAAoC,SAAS,qBAAqB;IAAW;GAAE;EACpH,GAAG,EAAE,aAAY,aAAY,YAAY,KAAK,QAAQ,EAAE,CAAC,CAAC;EAE1D,2BAA2B,OAA6B,kCAAkC,kBAAkB;EAM5G,MAAM,mBAAmB;GAAE,IAAI,MAAM,eAAe,YAAY;IAAE,MAAM,YAAY;IAAO,MAAM,SAAS,KAAA;GAAU;EAAE,CAAC;CACzH,EAAA,CAAG,CAAC,CAAC,OAAM,UAAS;EAGlB,IAAI,SAAS,OAAO,UAAU,UAAU,MAAmC,gBAAgB;EAG3F,IAAI,MAAM,eAAe,YAAY;GACnC,MAAM,YAAY;GAClB,MAAM,QAAQ;EAChB;EACA,MAAM;CACR,CAAC;CAKD,IAAI,YAAY,CAAC,CAAC;CAClB,MAAM,QAAQ,IAAI,WAAW,CAAC,CAAC;CAC/B,MAAM,YAAY,CAAC,CAAC;CACpB,MAAM,QAAQ;CACd,OAAO;AACT;AAEA,SAAS,SAAS,OAAoB,KAA6B;CACjE,IAAI,CAAC,MAAM,SAAS;EAClB,MAAM,SAAS;EACf,MAAM,OAAO,IAAI;EACjB,MAAM,WAAW,YAAY;GAE3B,MAAM,MAAM,WAAW,YAAY,CAAC,CAAC;GACrC,MAAM,MAAM,KAAK,YAAY,CAAC,CAAC;GAC/B,IAAI;IAAE,MAAM,MAAM,OAAO,QAAQ;GAAE,SAAS,OAAO;IAGjD,IAAI;KAAE,IAAoE,QAAQ,KAAK,yCAAyC,KAAK;IAAE,QACjI,CAAkB;GAC1B;GACA,IAAI;IACF,IAAI,KAAK,IAAA,qBAA6B,KAAK,MAAM,OAAO,MAAM,gBAAgB;GAChF,QAAQ,CAA0B;GAClC,MAAM,gBAAgB,KAAA;EACxB,EAAA,CAAG;CACL;CACA,OAAO,MAAM;AACf;AAEA,SAAS,kBAAkB,KAA2B;CACpD,MAAM,OAAO,IAAI;CACjB,MAAM,QAAqB;EACzB,OAAO;EAAG,QAAQ;EAAO,WAAW;EAAO,YAAY;EAAG,MAAM,QAAQ,QAAQ;EAChF,MAAM;GAAE,UAAU;GAAG,OAAO;EAAK;EACjC,WAAW;GAGT,IAAI,KAAK,OAAO,MAAM,KAAK;GAG3B,IAAI,CAAC,KAAK,WAAW,MAAM,IAAI,MAAM,yDAAyD;GAC9F,OAAO,KAAK;EACd;EACA,OAAO,QAAQ,QAAQ;EACvB,WAAW;GAAE,OAAO,SAAS,MAAM,GAAG;EAAE;CAC1C;CAKA,MAAM,WAAW,OAAO,QAAO,YAAW;EACxC,IAAI;GAAE,OAAO,KAAK,IAAI,SAAS,IAAI,MAAM,KAAA;EAAU,QAAQ;GAAE,OAAO;EAAK;CAC3E,CAAC;CACD,IAAI,SAAS,QACX,MAAM,IAAI,MAAM,wCAAwC,SAAS,KAAK,IAAI,EAAE,yBAAyB;CAEvG,MAAM,QAAQ,KAAK,OAAO,SAAQ,UAAS;EAAE,MAAM,YAAY,UAAU,OAAO,KAAK,KAAK;CAAE,CAAC;CAC7F,MAAM,QAAQ,QAAQ,QAAQ,MAAM,KAAK,CAAC,CAAC,WAAW;EAKpD,IAAI,CAAC,MAAM,WAAW,MAAM,4BAAY,IAAI,MAAM,oEAAoE,CAAC;EACvH,OAAO,MAAM;CACf,CAAC;CAKD,MAAM,MAAM,OAAM,UAAS;EACzB,MAAM,MAAO,MAAqC;EAClD,IAAI,QAAQ,KAAA,KAAa,QAAQ,MAAM,YAAY;EACnD,MAAM,QAAQ,MAAM,SAAS;EAC7B,MAAM,SAAS;EACf,MAAW,SAAS;CACtB,CAAC;CAGD,MAAM,gBAAgB,KAAK,QAAQ,yBAAyB,KAAK;CACjE,OAAO;AACT;AAEA,eAAe,aAAa,KAAoC;CAC9D,MAAM,OAAO,IAAI;CACjB,SAAS;EACP,MAAM,WAAW,KAAK,IAAI,yBAAyB,KAAK;EACxD,IAAI,UAAU;GACZ,IAAI,CAAC,SAAS,QAAQ,OAAO;GAE7B,MAAM,SAAS;GACf;EACF;EACA,OAAO,kBAAkB,GAAG;CAC9B;AACF;;;;;;;;;;;;;;;AAgBA,eAAsB,qBAAqB,KAA4C;CACrF,SAAS;EACP,MAAM,QAAQ,MAAM,aAAa,GAAG;EAIpC,MAAM,MAAM;EACZ,IAAI,MAAM,QAAQ;EAClB,MAAM,SAAS;EACf,IAAI,WAAW;EACf,OAAO,YAAY;GACjB,IAAI,UAAU;GACd,WAAW;GACX,MAAM,SAAS;GACf,IAAI,MAAM,SAAS,GAAG,MAAM,MAAM,SAAS;QACtC,MAAM,MAAM,KAAK,YAAY,CAAC,CAAC;EACtC;CACF;AACF"}
@@ -1,3 +1,5 @@
1
+ import { ReactNode } from "react";
2
+ import { Context } from "@deepseek-ai/cordis";
1
3
  //#region src/client-utils.d.ts
2
4
  /** Fixed pause for failing background reads; manual work is never replayed. */
3
5
  declare function createPluginReadGate(now?: () => number): {
@@ -53,6 +55,48 @@ declare function useNativeSeat(client: NativeSurfaceClient, props?: unknown): {
53
55
  };
54
56
  /** Refresh on native log boundaries/reconnect/focus. Read-only settling is bounded to an observed job. */
55
57
  declare function useFeatureRefresh(client: NativeSurfaceClient, sessionId: string, refresh: () => void, working?: boolean, enabled?: boolean): void;
58
+ /** A row contributed into the shared Session Management page. */
59
+ interface SessionSettingsItemSpec {
60
+ readonly id: string;
61
+ readonly order: number;
62
+ }
63
+ /** Owner props the host's `settings.section.icon` seat hands to its cell. */
64
+ interface SettingsSectionIconProps {
65
+ readonly size?: number;
66
+ /** Selection state; the nav styles active icons through color, not geometry. */
67
+ readonly active?: boolean;
68
+ }
69
+ /** Chat bubble for the shared Session Management section. */
70
+ declare const IconSessionSettings: (props: SettingsSectionIconProps) => ReactNode;
71
+ /** Serif-flavoured letter A for the Font section. */
72
+ declare const IconFontSettings: (props: SettingsSectionIconProps) => ReactNode;
73
+ /** Keyboard for the Shortcuts section. */
74
+ declare const IconKeyboardSettings: (props: SettingsSectionIconProps) => ReactNode;
75
+ /** Linked model nodes for the Model Management section. */
76
+ declare const IconModelNetworkSettings: (props: SettingsSectionIconProps) => ReactNode;
77
+ /**
78
+ * Contribute the nav icon of a `settings.section` entry. The `settings
79
+ * .section.icon` seat is declared by the host independently of the section
80
+ * list, so the wait runs in parallel: the icon appears as soon as both seats
81
+ * exist and unloading removes it again, restoring the host's default glyph.
82
+ * The `id` must equal the section id; `order`/`priority` mirror `order` so the
83
+ * icon cell sorts like its section and, for shared sections, several
84
+ * contributors can register the same icon id under different priorities
85
+ * without duplicating nav entries.
86
+ */
87
+ declare function registerSettingsSectionIcon(ctx: Context, sectionId: string, order: number, component: (props: SettingsSectionIconProps) => ReactNode): () => void;
88
+ /**
89
+ * Contribute one row to the shared Session Management settings page.
90
+ *
91
+ * `ctx` is the plugin's own context: the row's slot wait unloads with the
92
+ * plugin, while the page itself is retained on the shared root context for as
93
+ * long as any contributor lives. There is no fallback seat — when every
94
+ * contributor is gone the section disappears with the page. Returns a disposer.
95
+ * Shared root-owned infrastructure (the auxiliary-model card) calls this on
96
+ * `ctx.root` so its row, icon and page share are owned by the root lifetime
97
+ * and released by the returned disposer, not by any plugin fiber.
98
+ */
99
+ declare function registerSessionSettingsItem(ctx: Context, item: SessionSettingsItemSpec, component: (props: never) => ReactNode): () => void;
56
100
  //#endregion
57
- export { NativeSurfaceClient, createPluginReadGate, selectedSessionId, useFeatureRefresh, useNativeSeat };
101
+ export { IconFontSettings, IconKeyboardSettings, IconModelNetworkSettings, IconSessionSettings, NativeSurfaceClient, SessionSettingsItemSpec, SettingsSectionIconProps, createPluginReadGate, registerSessionSettingsItem, registerSettingsSectionIcon, selectedSessionId, useFeatureRefresh, useNativeSeat };
58
102
  //# sourceMappingURL=client-utils.d.ts.map
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { SESSION_SETTINGS_ITEM_SLOT, SESSION_SETTINGS_SECTION_ID, SETTINGS_SECTION_ICON_SLOT } from "./contracts.js";
2
+ import { createElement, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
3
  //#region src/client-utils.ts
3
4
  /** Fixed pause for failing background reads; manual work is never replayed. */
4
5
  function createPluginReadGate(now = Date.now) {
@@ -112,7 +113,191 @@ function useFeatureRefresh(client, sessionId, refresh, working = false, enabled
112
113
  enabled
113
114
  ]);
114
115
  }
116
+ /** One `<svg>` with the shared icon chrome; children are plain elements. */
117
+ function lineIcon(children) {
118
+ return function SettingsSectionIcon(props) {
119
+ return createElement("svg", {
120
+ "aria-hidden": "true",
121
+ width: props.size ?? 16,
122
+ height: props.size ?? 16,
123
+ viewBox: "0 0 16 16",
124
+ fill: "none",
125
+ stroke: "currentColor",
126
+ strokeWidth: 1,
127
+ strokeLinecap: "round",
128
+ strokeLinejoin: "round"
129
+ }, ...children.map(([tag, attrs], index) => createElement(tag, {
130
+ key: index,
131
+ ...attrs
132
+ })));
133
+ };
134
+ }
135
+ /** Chat bubble for the shared Session Management section. */
136
+ const IconSessionSettings = lineIcon([["path", { d: "M8.5 13a5.5 5.5 0 0 0 5.5-5.5A5.5 5.5 0 0 0 8.5 2 5.5 5.5 0 0 0 3 7.5a5.48 5.48 0 0 0 .9 3L2.8 12.6a.5.5 0 0 0 .62.62l2.9-.98a5.48 5.48 0 0 0 2.18.76Z" }]]);
137
+ /** Serif-flavoured letter A for the Font section. */
138
+ const IconFontSettings = lineIcon([
139
+ ["path", { d: "M3.3 12.6 7.9 3.1a.5.5 0 0 1 .9 0l4.6 9.5" }],
140
+ ["path", { d: "M5.2 9.4h6.3" }],
141
+ ["path", { d: "M2.4 12.6h2.4" }],
142
+ ["path", { d: "M11.9 12.6h2.4" }]
143
+ ]);
144
+ /** Keyboard for the Shortcuts section. */
145
+ const IconKeyboardSettings = lineIcon([
146
+ ["rect", {
147
+ x: 1.75,
148
+ y: 4.75,
149
+ width: 12.5,
150
+ height: 7.5,
151
+ rx: 1.2
152
+ }],
153
+ ["path", { d: "M4.5 7.25h0" }],
154
+ ["path", { d: "M8 7.25h0" }],
155
+ ["path", { d: "M11.5 7.25h0" }],
156
+ ["path", { d: "M5.25 9.75h5.5" }]
157
+ ]);
158
+ /** Linked model nodes for the Model Management section. */
159
+ const IconModelNetworkSettings = lineIcon([
160
+ ["line", {
161
+ x1: 10.6,
162
+ y1: 5.3,
163
+ x2: 8.8,
164
+ y2: 7.1
165
+ }],
166
+ ["line", {
167
+ x1: 7.2,
168
+ y1: 8.9,
169
+ x2: 5.4,
170
+ y2: 10.7
171
+ }],
172
+ ["circle", {
173
+ cx: 12,
174
+ cy: 4,
175
+ r: 1.9
176
+ }],
177
+ ["circle", {
178
+ cx: 4,
179
+ cy: 12,
180
+ r: 1.9
181
+ }],
182
+ ["circle", {
183
+ cx: 8,
184
+ cy: 8,
185
+ r: 1.5
186
+ }]
187
+ ]);
188
+ /**
189
+ * Contribute the nav icon of a `settings.section` entry. The `settings
190
+ * .section.icon` seat is declared by the host independently of the section
191
+ * list, so the wait runs in parallel: the icon appears as soon as both seats
192
+ * exist and unloading removes it again, restoring the host's default glyph.
193
+ * The `id` must equal the section id; `order`/`priority` mirror `order` so the
194
+ * icon cell sorts like its section and, for shared sections, several
195
+ * contributors can register the same icon id under different priorities
196
+ * without duplicating nav entries.
197
+ */
198
+ function registerSettingsSectionIcon(ctx, sectionId, order, component) {
199
+ const slots = ctx.slots;
200
+ return slots.inject(SETTINGS_SECTION_ICON_SLOT, () => slots.register({
201
+ name: SETTINGS_SECTION_ICON_SLOT,
202
+ id: sectionId,
203
+ order,
204
+ priority: order
205
+ }, component));
206
+ }
207
+ /** Private Cordis rendezvous; every bundled copy of this helper resolves the same root service. */
208
+ const SESSION_SETTINGS_PAGE_SERVICE = "dshSessionSettingsPage";
209
+ /** The page component renders only its declared child list: items paint their own titles and styles. */
210
+ function SessionSettingsPageView(props) {
211
+ return props.renderSlot(SESSION_SETTINGS_ITEM_SLOT, {});
212
+ }
213
+ /**
214
+ * Acquire the root-owned page service, creating and providing it on first use.
215
+ * One counter covers participants, the section wait and the provider: the last
216
+ * release removes the wait and then the provider synchronously (Cordis deletes
217
+ * the impl before its async disposer awaits), so a later acquire sees a clean
218
+ * get() miss and can re-establish the page.
219
+ */
220
+ function acquireSessionSettingsPage(root) {
221
+ const existing = root.get(SESSION_SETTINGS_PAGE_SERVICE, false);
222
+ if (existing) return existing;
223
+ const slots = root.slots;
224
+ let users = 0;
225
+ let stopPage;
226
+ let unprovide;
227
+ const page = {
228
+ retain() {
229
+ users += 1;
230
+ if (!stopPage) stopPage = slots.inject("settings.section", () => slots.register({
231
+ name: "settings.section",
232
+ id: SESSION_SETTINGS_SECTION_ID,
233
+ order: 30,
234
+ label: () => {
235
+ const locale = root.get("locale", false);
236
+ return String(locale?.getSnapshot?.().active ?? "").startsWith("zh") ? "会话管理" : "Session Management";
237
+ },
238
+ children: { [SESSION_SETTINGS_ITEM_SLOT]: {
239
+ kind: "list",
240
+ scope: "root"
241
+ } }
242
+ }, SessionSettingsPageView));
243
+ },
244
+ release() {
245
+ if (users <= 0) return;
246
+ users -= 1;
247
+ if (users > 0) return;
248
+ stopPage?.();
249
+ stopPage = void 0;
250
+ unprovide?.();
251
+ }
252
+ };
253
+ unprovide = root.provide(SESSION_SETTINGS_PAGE_SERVICE, page);
254
+ return page;
255
+ }
256
+ /**
257
+ * Contribute one row to the shared Session Management settings page.
258
+ *
259
+ * `ctx` is the plugin's own context: the row's slot wait unloads with the
260
+ * plugin, while the page itself is retained on the shared root context for as
261
+ * long as any contributor lives. There is no fallback seat — when every
262
+ * contributor is gone the section disappears with the page. Returns a disposer.
263
+ * Shared root-owned infrastructure (the auxiliary-model card) calls this on
264
+ * `ctx.root` so its row, icon and page share are owned by the root lifetime
265
+ * and released by the returned disposer, not by any plugin fiber.
266
+ */
267
+ function registerSessionSettingsItem(ctx, item, component) {
268
+ const slots = ctx.slots;
269
+ const root = ctx.root;
270
+ const page = acquireSessionSettingsPage(root);
271
+ let stopItem;
272
+ let stopIcon;
273
+ try {
274
+ page.retain();
275
+ stopItem = slots.inject(SESSION_SETTINGS_ITEM_SLOT, () => slots.register({
276
+ name: SESSION_SETTINGS_ITEM_SLOT,
277
+ ...item
278
+ }, component));
279
+ stopIcon = slots.inject(SETTINGS_SECTION_ICON_SLOT, () => slots.register({
280
+ name: SETTINGS_SECTION_ICON_SLOT,
281
+ id: SESSION_SETTINGS_SECTION_ID,
282
+ order: item.order,
283
+ priority: item.order
284
+ }, IconSessionSettings));
285
+ } catch (error) {
286
+ stopIcon?.();
287
+ stopItem?.();
288
+ page.release();
289
+ throw error;
290
+ }
291
+ let disposed = false;
292
+ return () => {
293
+ if (disposed) return;
294
+ disposed = true;
295
+ stopIcon?.();
296
+ stopItem?.();
297
+ page.release();
298
+ };
299
+ }
115
300
  //#endregion
116
- export { createPluginReadGate, selectedSessionId, useFeatureRefresh, useNativeSeat };
301
+ export { IconFontSettings, IconKeyboardSettings, IconModelNetworkSettings, IconSessionSettings, createPluginReadGate, registerSessionSettingsItem, registerSettingsSectionIcon, selectedSessionId, useFeatureRefresh, useNativeSeat };
117
302
 
118
303
  //# sourceMappingURL=client-utils.js.map