dsh-model-switch 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## v0.4.29
2
+
3
+ The experimental "随对话更新的标题" bundle (`dsh-experimental-session-titles-bundle`) no longer fails to enable while this plugin is active: the naming row now takes over the single provider slot the bundle's row competes for, mirrors that row's cadence, and keeps the configurable naming route working.
4
+
5
+ - **What broke.** Enabling the bundle produced `1 entry did not activate optional-session-title-all-prompts (@deepseek-ai/dsh-session-title-all-prompts-llm): session-title provider "session-title-first-prompt-llm" is already registered`. DSH 0.2.1's session-title service accepts exactly one registered provider, and this row already owned that slot with its mirrored first-prompt provider. The bundle's patch inserts its all-prompts row **after** this plugin's patch layer, so the static `- id: optional-session-title-all-prompts` disable this plugin ships can never reach a row that does not exist yet when that layer applies.
6
+ - **Fix: the row now takes the slot over instead of colliding (`src/session-title.ts`).** It mirrors the official row that exists in the tree — all-prompts winning whenever the experimental row is composed, first-prompt otherwise — and it watches `loader/patch-context`, which the loader runs inside the official entry before that entry's plugin `apply`. When an official naming row is about to register, this row yields the slot first (the service lets a registration be replaced once its disposer ran), so the official `register()` **succeeds** and the loader reports no failed activation; it then disables that row through the public `Entry.update({ disabled: true })` (which disposes the row's fiber and closes its registration) and claims the slot back under the row's cadence. A bounded retry absorbs the cases where the loader has not created the row's fiber yet, and an initial sweep handles the reverse composition order (an official row that activated before this row started). Only seams the loader already publishes are used: the `loader/patch-context` waterfall, `Entry.update`, and `sessionTitle.register` plus its disposer.
7
+ - **User-visible behavior.** Enabling the bundle: titles now update with every prompt **and** the configured naming model / provider-group route keeps applying (the provider id recorded with each title is `session-title-all-prompts-llm`, matching what the official row would record). Disabling the bundle: the first-prompt cadence comes back with the same route override. A Host that changes activation timing degrades to "the official provider serves naming, one explicit log line" rather than a crash or a wrong route.
8
+ - **Verification.** `pnpm run check` green (53 test files / 466 tests + pack gate). New coverage: the all-prompts row wins the mirror, the takeover yields before the official registration and reclaims the slot with the mirrored cadence, repeat patch-context events are idempotent, an already-active row is swept, and no official row keeps today's single first-prompt registration. Real published packages on `0.2.1-rc.2` (the real `dsh-session-title` service plus the real `dsh-session-title-all-prompts-llm` apply): the official row registers with **no** `already registered` error, the displaced row is disabled through `Entry.update`, the plugin owns the slot again, and the live provider is `session-title-all-prompts-llm` / `all-prompts`.
9
+ - **Residual (honest).** The takeover choreographs released loader seams rather than a dedicated "supersede this provider" API; `docs/seam-gap.md` records the missing seam and the upstream proposal (a decorate/replace registration or a multi-provider slot). The reverse direction — disabling the bundle at runtime without a profile restart — re-claims through `loader/partial-dispose` where the loader emits it, and otherwise needs the restart DSH already asks for on bundle changes.
10
+
1
11
  ## v0.4.28
2
12
 
3
13
  Two fixes for the same DSH `0.2.1-rc.2` line: re-enabling the plugin on 0.2.1 made **every tool call fail before its body ran**, and the Subagent routing adapter that 0.4.26 had to fail closed is adapted to the release's activation API.
package/PRODUCT.md CHANGED
@@ -33,4 +33,4 @@ Official `settings.section` has no icon field. The Settings nav glyph is a label
33
33
 
34
34
  ## Delivery architecture
35
35
 
36
- The package foundation and pure deep modules remain the policy/test surface. Runtime delivery disables the independent Subagent Loader row with its official name and inserts a uniquely identified replacement; the experimental auto-review row is disabled the same way and replaced by `dsh-model-switch/auto-review`, which mounts the official gate's `apply` unchanged as a plugin under a scoped `llm` shadow — the Host resolves the gate's own `inject` list (including 0.2.1's `workingDirectory`), one `registerAuto` runs, and only when the profile started with the optional auto-review bundle. Official AgentPresets stay mounted so they share the process `dsh-scope` identity. Search uses the released `ctx.web.registerSearchProvider()` seam so official tool ownership is preserved. Image uses a unique tool name and provider-owned authenticated adapters. Self-maintained Codex/Grok plugins register optional adapters through the public Model Switch service when present; standalone provider behavior is unchanged. Vision remains disabled. Replacement modules preserve the official public interfaces and fail closed when compatibility or capability validation fails.
36
+ The package foundation and pure deep modules remain the policy/test surface. Runtime delivery disables the independent Subagent Loader row with its official name and inserts a uniquely identified replacement; the experimental auto-review row is disabled the same way and replaced by `dsh-model-switch/auto-review`, which mounts the official gate's `apply` unchanged as a plugin under a scoped `llm` shadow — the Host resolves the gate's own `inject` list (including 0.2.1's `workingDirectory`), one `registerAuto` runs, and only when the profile started with the optional auto-review bundle. Session naming replaces the official `session-title-llm` row and owns the service's single provider slot; it mirrors the official cadence the profile declares — first-prompt by default, and the all-prompts cadence whenever the experimental session-titles bundle inserts its row — so enabling that bundle keeps both the per-prompt title refresh and this plugin's configurable naming route, while disabling it restores the first-prompt cadence. The takeover only uses released loader seams (the `loader/patch-context` waterfall, `Entry.update`, and `sessionTitle.register` plus its disposer) and degrades to the official provider with one explicit log line if a future Host changes activation timing. Official AgentPresets stay mounted so they share the process `dsh-scope` identity. Search uses the released `ctx.web.registerSearchProvider()` seam so official tool ownership is preserved. Image uses a unique tool name and provider-owned authenticated adapters. Self-maintained Codex/Grok plugins register optional adapters through the public Model Switch service when present; standalone provider behavior is unchanged. Vision remains disabled. Replacement modules preserve the official public interfaces and fail closed when compatibility or capability validation fails.
package/README.zh.md CHANGED
@@ -119,6 +119,8 @@ DSH 会自动为新会话起名;命名的触发节奏由 profile 的官方命
119
119
 
120
120
  路由在**每次命名请求时现读**:保存后对下一次起名立即生效、无需重启,且进行中的命名请求不会被保存打断。每次请求按顺序解析路由:该会话供应商组里的命名对,其次卡片的部署级对,其次会话自身的模型。请求经由官方命名实现发出,它会把实际使用的 provider/model 记录到该会话自己的 `session/title-llm-request` 事件里,因此持久记录与真实请求永远一致。本插件替换官方的 `session-title-llm` 行而不分叉命名逻辑(该接缝每个上下文只允许注册一个命名 provider),并镜像该行声明的节奏;profile 没有官方命名行时保持 DSH 的确定性兜底标题。
121
121
 
122
+ 0.4.29 起与实验包「随对话更新的标题」(`dsh-experimental-session-titles-bundle`,插入 all-prompts 命名行)兼容:启用该实验包时,本插件接管同一个命名 provider 槽位并镜像 all-prompts 节奏,标题随对话更新,同时命名模型路由与供应商组路由继续生效(`session/title` 记录中的 provider id 为 `session-title-all-prompts-llm`,与官方行一致);禁用实验包即回到首条提示节奏与同样的路由覆写。实现只使用 loader 已发布的缝(`loader/patch-context` 瀑布、`Entry.update`、`sessionTitle.register` 及其 disposer),让官方行先成功注册、再被停用并由本行重占槽位,因此启用时不再出现 `already registered` 激活失败弹窗。
123
+
122
124
  ## 供应商配置组(Provider groups)
123
125
 
124
126
  「供应商配置组」卡片为每个供应商保存一组路由——压缩、自动审查、会话命名与子代理四条路由一起存;为供应商 X 保存的组,作用于自身供应商就是 X 的会话。组是**推导出来的,而不是写进去的**:切换某个会话的供应商不写任何东西,因此不同供应商上的多个会话并行时再也不会互相覆盖、互相改写对方的辅助路由。
@@ -148,19 +148,32 @@ function selectTitleMessages(messages, firstPromptOnly) {
148
148
  if (first === void 0) throw new Error("first-prompt title provider requires one human message");
149
149
  return [first];
150
150
  }
151
+ /** The id the experimental session-titles bundle inserts for all-prompts naming. */
152
+ const ALL_PROMPTS_ROW = "optional-session-title-all-prompts";
151
153
  /**
152
154
  * Locate the official session-title provider row in the loader tree.
155
+ *
153
156
  * Disabled entries stay in the tree, so a name-matched disable still matches.
157
+ * The all-prompts row wins when it is present: that row only exists while the
158
+ * experimental session-titles bundle is composed, and its presence is exactly
159
+ * the user's choice to update titles with every prompt. The default
160
+ * first-prompt row is the fallback when that bundle is off.
154
161
  * @param loader - the host loader, when one is reachable.
155
- * @returns The official entry, or undefined when this profile has no such row.
162
+ * @returns The official entry to mirror, or undefined when this profile has no such row.
156
163
  */
157
164
  function officialTitleEntry(loader) {
158
165
  if (!loader) return void 0;
166
+ const allPrompts = TITLE_CADENCES[1];
167
+ const firstPrompt = TITLE_CADENCES[0];
168
+ let fallback;
159
169
  for (const entry of loader.entries()) {
160
- if (entry.id === OFFICIAL_TITLE_ROW) return entry;
170
+ if (entry.id === ALL_PROMPTS_ROW) return entry;
161
171
  const pkg = entry.options?.name;
162
- if (typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg)) return entry;
172
+ if (typeof pkg === "string" && allPrompts !== void 0 && pkg === allPrompts.pkg) return entry;
173
+ if (fallback === void 0 && entry.id === OFFICIAL_TITLE_ROW) fallback = entry;
174
+ else if (fallback === void 0 && firstPrompt !== void 0 && pkg === firstPrompt.pkg) fallback = entry;
163
175
  }
176
+ return fallback;
164
177
  }
165
178
  /**
166
179
  * Copy the official row's stored config, ignoring non-object payloads.
@@ -517,6 +530,214 @@ function currentSettings(ctx) {
517
530
  return;
518
531
  }
519
532
  }
533
+ /** Resolve the naming plan for one official row, or the shipped defaults when no row exists. */
534
+ function planFor(ctx, module, entry) {
535
+ const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
536
+ if (cadence === void 0) return void 0;
537
+ const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
538
+ let policy;
539
+ try {
540
+ policy = resolveNamingPolicy(module, forwarded);
541
+ } catch (cause) {
542
+ ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
543
+ ctx.logger.error(cause);
544
+ return;
545
+ }
546
+ return {
547
+ cadence,
548
+ config: policy.config,
549
+ targets: policy.targets
550
+ };
551
+ }
552
+ /** One naming plan bound to a live route resolver. */
553
+ function providerOf(ctx, module, plan) {
554
+ return {
555
+ id: plan.cadence.id,
556
+ automatic: plan.cadence.automatic,
557
+ generate: (request) => generateTitle(ctx, module, plan, request)
558
+ };
559
+ }
560
+ /** Timing knobs for the takeover choreography (exported for tests). */
561
+ const TAKEOVER_RETRY_DELAY_MS = 10;
562
+ /** How many times the takeover re-checks after disabling the official row. */
563
+ const TAKEOVER_MAX_ATTEMPTS = 20;
564
+ /**
565
+ * The single naming-provider slot this row serves.
566
+ *
567
+ * DSH's session-title service accepts exactly one registered provider, and the
568
+ * official rows (`session-title-llm`, and the experimental row the
569
+ * `dsh-experimental-session-titles-bundle` inserts) compete for the same slot.
570
+ * This row therefore mirrors one official row and owns the slot; when another
571
+ * official row activates anyway (the experimental bundle inserts its row after
572
+ * this plugin's patch layer, so a static `disabled` cannot reach it), it yields
573
+ * the slot so that row's own registration succeeds — no activation warning —
574
+ * and then disables that row and takes the slot back with its cadence mirrored.
575
+ * Every step uses released seams: the `loader/patch-context` waterfall, the
576
+ * public `Entry.update`, and `sessionTitle.register` plus its disposer.
577
+ */
578
+ var NamingSeam = class {
579
+ ctx;
580
+ module;
581
+ disposer;
582
+ plan;
583
+ constructor(ctx, module, plan) {
584
+ this.ctx = ctx;
585
+ this.module = module;
586
+ this.plan = plan;
587
+ }
588
+ /** The cadence currently registered. */
589
+ get cadence() {
590
+ return this.plan.cadence;
591
+ }
592
+ /** Whether this row currently owns the slot. */
593
+ get claimed() {
594
+ return this.disposer !== void 0;
595
+ }
596
+ /**
597
+ * Register this row's provider with the given plan.
598
+ * @param plan - the naming plan to serve (defaults to the current one).
599
+ * @returns true when the slot is ours, false when another provider holds it.
600
+ */
601
+ claim(plan = this.plan) {
602
+ this.plan = plan;
603
+ if (this.disposer !== void 0) return true;
604
+ try {
605
+ this.disposer = this.ctx.sessionTitle.register(providerOf(this.ctx, this.module, plan));
606
+ return true;
607
+ } catch {
608
+ return false;
609
+ }
610
+ }
611
+ /** Release the slot (the service lets the next registration replace a closing one). */
612
+ yieldSlot() {
613
+ const disposer = this.disposer;
614
+ this.disposer = void 0;
615
+ if (disposer === void 0) return;
616
+ try {
617
+ disposer();
618
+ } catch (cause) {
619
+ this.ctx.logger.error("model-switch: releasing the session-title provider failed");
620
+ this.ctx.logger.error(cause);
621
+ }
622
+ }
623
+ /** Swap the served plan, re-registering only when the slot is already ours. */
624
+ retarget(plan) {
625
+ if (!this.claimed) {
626
+ this.plan = plan;
627
+ return;
628
+ }
629
+ this.yieldSlot();
630
+ this.claim(plan);
631
+ }
632
+ };
633
+ /** Whether one loader entry declares an official naming provider this row supersedes. */
634
+ function isOfficialTitleRow(entry) {
635
+ if (entry === void 0) return false;
636
+ if (entry.id === OFFICIAL_TITLE_ROW || entry.id === ALL_PROMPTS_ROW) return true;
637
+ const pkg = entry.options?.name;
638
+ return typeof pkg === "string" && TITLE_CADENCES.some((cadence) => cadence.pkg === pkg);
639
+ }
640
+ /** The id one entry is known by (the loader's leaf id, not a nested path). */
641
+ function leafId(entry) {
642
+ const raw = entry.options?.id;
643
+ return typeof raw === "string" ? raw : void 0;
644
+ }
645
+ /**
646
+ * Install the takeover choreography for naming rows that activate after this one.
647
+ *
648
+ * `loader/patch-context` runs inside the official entry's own `_init`, before its
649
+ * plugin's `apply`, and it receives that entry. At that point the entry's
650
+ * `options` are final, so this row can yield the slot first — the official
651
+ * `register()` then succeeds instead of throwing the "already registered"
652
+ * activation warning — and afterwards disable that row through the public
653
+ * `Entry.update` and take the slot back with its cadence mirrored.
654
+ * @param ctx - this row's context.
655
+ * @param module - the auxiliary-LLM package.
656
+ * @param seam - the slot owner.
657
+ * @returns Nothing; the listeners are effects of this row's fiber.
658
+ */
659
+ function installTakeover(ctx, module, seam) {
660
+ /** Entries already handled, so a repeated patch-context does not loop. */
661
+ const handled = /* @__PURE__ */ new Set();
662
+ /** Entries with a reclaim cycle in flight, so two triggers cannot race. */
663
+ const reclaiming = /* @__PURE__ */ new Set();
664
+ /** After the yield, disable the official row and take the slot back. */
665
+ const reclaim = async (entry, plan) => {
666
+ if (reclaiming.has(entry)) return;
667
+ reclaiming.add(entry);
668
+ try {
669
+ for (let attempt = 0; attempt < 20; attempt += 1) {
670
+ await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 0 : 10));
671
+ try {
672
+ await entry.update({ disabled: true });
673
+ } catch (cause) {
674
+ ctx.logger.info("model-switch: the displaced naming row could not be persisted as disabled; the takeover repeats next startup");
675
+ ctx.logger.info(String(cause));
676
+ }
677
+ if (seam.claim(plan)) {
678
+ ctx.logger.info(`model-switch: displaced the official naming row ${String(leafId(entry) ?? "unknown")}; its ${plan.cadence.automatic} cadence and policy are mirrored by the configurable naming route`);
679
+ return;
680
+ }
681
+ }
682
+ ctx.logger.error(`model-switch: the official naming row ${String(leafId(entry) ?? "unknown")} kept the session-title provider slot; the configurable naming route is unavailable this session`);
683
+ } finally {
684
+ reclaiming.delete(entry);
685
+ }
686
+ };
687
+ /**
688
+ * The row an event names.
689
+ *
690
+ * The loader hands the emitting entry as the event's `this`, but cordis does
691
+ * not rebind `this` for listeners added through `ctx.on`, so a listener can
692
+ * also receive the payload as its first argument. Both shapes are accepted;
693
+ * the real Host always provides one of them.
694
+ */
695
+ const entryOfEvent = (self, first) => {
696
+ const candidate = self ?? first;
697
+ return candidate !== null && typeof candidate === "object" && "options" in candidate ? candidate : void 0;
698
+ };
699
+ const listeners = [];
700
+ const listen = (event, listener) => {
701
+ const dispose = ctx.on?.(event, listener);
702
+ if (dispose !== void 0) listeners.push(dispose);
703
+ };
704
+ listen("loader/patch-context", function(first, second) {
705
+ const nextFn = typeof first === "function" ? first : second;
706
+ const entry = entryOfEvent(this, typeof first === "function" ? void 0 : first);
707
+ if (entry === void 0 || typeof nextFn !== "function" || !isOfficialTitleRow(entry) || handled.has(entry)) return nextFn === void 0 ? void 0 : nextFn();
708
+ handled.add(entry);
709
+ const plan = planFor(ctx, module, entry);
710
+ if (plan !== void 0) {
711
+ seam.yieldSlot();
712
+ reclaim(entry, plan);
713
+ }
714
+ return nextFn();
715
+ });
716
+ listen("loader/partial-dispose", function(first) {
717
+ const entry = entryOfEvent(this, first);
718
+ if (entry === void 0 || !isOfficialTitleRow(entry)) return;
719
+ handled.delete(entry);
720
+ if (seam.claimed) return;
721
+ const plan = planFor(ctx, module, officialTitleEntry(ctx.get("loader", false)));
722
+ if (plan !== void 0) seam.claim(plan);
723
+ });
724
+ const loader = ctx.get("loader", false);
725
+ if (typeof loader?.entries === "function") for (const candidate of loader.entries()) {
726
+ if (!isOfficialTitleRow(candidate)) continue;
727
+ if (candidate.disabled === true) continue;
728
+ const plan = planFor(ctx, module, candidate);
729
+ if (plan !== void 0) reclaim(candidate, plan);
730
+ }
731
+ const disposable = () => {
732
+ for (const dispose of listeners.splice(0)) dispose();
733
+ };
734
+ const effect = ctx.effect;
735
+ if (effect === void 0) return;
736
+ const registration = effect(function* () {
737
+ yield disposable;
738
+ }, "model-switch: session-title takeover");
739
+ if (typeof registration === "function") listeners.push(registration);
740
+ }
520
741
  /**
521
742
  * Mount the Model Switch session-naming row for this profile.
522
743
  *
@@ -529,11 +750,7 @@ function currentSettings(ctx) {
529
750
  */
530
751
  async function apply(ctx) {
531
752
  const entry = officialTitleEntry(ctx.get("loader", false));
532
- const cadence = titleCadence(entry === void 0 ? void 0 : officialTitlePackage(entry)) ?? TITLE_CADENCES[0];
533
- if (cadence === void 0) return;
534
753
  if (entry === void 0) ctx.logger.info("model-switch: no instantiated official session-title row to mirror; naming uses the first-prompt cadence");
535
- /** The displaced row's naming policy, or the shipped defaults when it is absent. */
536
- const forwarded = entry === void 0 ? {} : forwardedConfig(entry);
537
754
  let module;
538
755
  try {
539
756
  module = await import(__rewriteRelativeImportExtension(TITLE_LLM_PACKAGE));
@@ -542,29 +759,11 @@ async function apply(ctx) {
542
759
  ctx.logger.error(cause);
543
760
  return;
544
761
  }
545
- let policy;
546
- try {
547
- policy = resolveNamingPolicy(module, forwarded);
548
- } catch (cause) {
549
- ctx.logger.error("model-switch: the official session-title row has no valid naming policy; naming keeps the deterministic fallback");
550
- ctx.logger.error(cause);
551
- return;
552
- }
553
- const plan = {
554
- cadence,
555
- config: policy.config,
556
- targets: policy.targets
557
- };
558
- try {
559
- ctx.sessionTitle.register({
560
- id: cadence.id,
561
- automatic: cadence.automatic,
562
- generate: (request) => generateTitle(ctx, module, plan, request)
563
- });
564
- } catch (cause) {
565
- ctx.logger.error("model-switch: another session-title provider is already registered; the configurable naming route is unavailable");
566
- ctx.logger.error(cause);
567
- }
762
+ const plan = planFor(ctx, module, entry);
763
+ if (plan === void 0) return;
764
+ const seam = new NamingSeam(ctx, module, plan);
765
+ if (!seam.claim()) ctx.logger.info("model-switch: another session-title provider already owns the seam; naming follows it while it is active");
766
+ installTakeover(ctx, module, seam);
568
767
  }
569
768
  //#endregion
570
- export { TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, frameTitleInput, inject, isMaxTokensFailure, isModernTitleModule, name, normalizeTitleText, officialTitleEntry, officialTitlePackage, resolveNamingPolicy, selectTitleMessages, selectTitleReasoningEffort, shouldTryNextCandidate, splitTitlePolicy, titleCadence, titleFromOutput, titleFromResponse, titleRouteCandidates, titleSystemPrompt, widerTitleBudget };
769
+ export { TAKEOVER_MAX_ATTEMPTS, TAKEOVER_RETRY_DELAY_MS, TITLE_CADENCES, apply, composeTitleRoute, forwardedConfig, frameTitleInput, inject, isMaxTokensFailure, isModernTitleModule, isOfficialTitleRow, name, normalizeTitleText, officialTitleEntry, officialTitlePackage, resolveNamingPolicy, selectTitleMessages, selectTitleReasoningEffort, shouldTryNextCandidate, splitTitlePolicy, titleCadence, titleFromOutput, titleFromResponse, titleRouteCandidates, titleSystemPrompt, widerTitleBudget };
@@ -138,9 +138,14 @@ export declare function composeTitleRoute(config: TitleLlmPolicy, route: Capabil
138
138
  export declare function selectTitleMessages(messages: readonly SessionTitleUserMessage[], firstPromptOnly: boolean): readonly SessionTitleUserMessage[];
139
139
  /**
140
140
  * Locate the official session-title provider row in the loader tree.
141
+ *
141
142
  * Disabled entries stay in the tree, so a name-matched disable still matches.
143
+ * The all-prompts row wins when it is present: that row only exists while the
144
+ * experimental session-titles bundle is composed, and its presence is exactly
145
+ * the user's choice to update titles with every prompt. The default
146
+ * first-prompt row is the fallback when that bundle is off.
142
147
  * @param loader - the host loader, when one is reachable.
143
- * @returns The official entry, or undefined when this profile has no such row.
148
+ * @returns The official entry to mirror, or undefined when this profile has no such row.
144
149
  */
145
150
  export declare function officialTitleEntry(loader: Loader | undefined): Entry | undefined;
146
151
  /**
@@ -270,6 +275,12 @@ export declare function titleSystemPrompt(targets: TitleLengthTargets, hasCurren
270
275
  * @returns The framed user input.
271
276
  */
272
277
  export declare function frameTitleInput(messages: readonly SessionTitleUserMessage[], currentTitle: string | undefined): string;
278
+ /** Timing knobs for the takeover choreography (exported for tests). */
279
+ export declare const TAKEOVER_RETRY_DELAY_MS = 10;
280
+ /** How many times the takeover re-checks after disabling the official row. */
281
+ export declare const TAKEOVER_MAX_ATTEMPTS = 20;
282
+ /** Whether one loader entry declares an official naming provider this row supersedes. */
283
+ export declare function isOfficialTitleRow(entry: Entry | undefined): boolean;
273
284
  /**
274
285
  * Mount the Model Switch session-naming row for this profile.
275
286
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.28",
3
+ "version": "0.4.29",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",