dsh-connect-modelscope-token-plan 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1540,8 +1540,8 @@ function createCoalescedFetch(options = {}) {
1540
1540
  * 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
1541
1541
  *
1542
1542
  * 维护纪律:
1543
- * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
1544
- * 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
1543
+ * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-23 实拉 `/v1/models` 全集
1544
+ * 37 个中筛出 21 个);目录下架的条目应随手删除,表不是历史档案。
1545
1545
  * 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
1546
1546
  * 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
1547
1547
  * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
@@ -1555,14 +1555,14 @@ function createCoalescedFetch(options = {}) {
1555
1555
  * 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
1556
1556
  * shapeWarning(见 {@link curatedCatalogStale})。
1557
1557
  */
1558
- const CURATED_AS_OF = "2026-10-16";
1558
+ const CURATED_AS_OF = "2026-10-23";
1559
1559
  /**
1560
1560
  * 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
1561
1561
  * @param {Date} [now] - 时钟源(测试注入)。
1562
1562
  * @returns {number|null} 天数(向下取整),或 null。
1563
1563
  */
1564
1564
  function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
1565
- const asOf = Date.parse("2026-10-16T00:00:00Z");
1565
+ const asOf = Date.parse("2026-10-23T00:00:00Z");
1566
1566
  if (Number.isNaN(asOf)) return null;
1567
1567
  const delta = now.getTime() - asOf;
1568
1568
  return delta < 0 ? 0 : Math.floor(delta / 864e5);
@@ -1586,6 +1586,7 @@ const CURATED_CONTEXT_WINDOWS = {
1586
1586
  "deepseek-ai/DeepSeek-V4-Pro": 1048576,
1587
1587
  "deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
1588
1588
  "deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
1589
+ "deepseek-ai/DeepSeek-V4.1-Flash-1017": 1048576,
1589
1590
  "meituan-longcat/LongCat-Flash-Lite": 256e3,
1590
1591
  "MiniMax/MiniMax-M1-80k": 1e6,
1591
1592
  "MiniMax/MiniMax-M3": 1048576,
@@ -1599,6 +1600,7 @@ const CURATED_CONTEXT_WINDOWS = {
1599
1600
  "Qwen/Qwen3.8-Flash-Next": 262144,
1600
1601
  "stepfun-ai/Step-3.5-Flash": 262144,
1601
1602
  "stepfun-ai/Step-3.7-Flash": 262144,
1603
+ "stepfun-ai/Step-5-Preview": 1e6,
1602
1604
  "ZhipuAI/GLM-4.7-Flash": 2e5,
1603
1605
  "ZhipuAI/GLM-5.2": 1e6
1604
1606
  };
@@ -2026,7 +2028,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
2026
2028
  * collapses here, so the three call sites that produce it stay in step instead
2027
2029
  * of each spelling out the same three `null`s.
2028
2030
  */
2029
- const EMPTY_PAYLOAD$2 = {
2031
+ const EMPTY_PAYLOAD$3 = {
2030
2032
  enabled: null,
2031
2033
  enabledIds: null,
2032
2034
  contextWindow: null,
@@ -2255,7 +2257,7 @@ function createFileProviderStore(options = {}) {
2255
2257
  * `null` branch collapses here, in one place, rather than at every caller.
2256
2258
  * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
2257
2259
  */
2258
- const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
2260
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$3;
2259
2261
  /**
2260
2262
  * Read one payload, then hand it to {@link writePayload} — the read-modify-
2261
2263
  * write that keeps the switch and the list from clobbering each other.
@@ -2479,7 +2481,7 @@ function createFileProviderStore(options = {}) {
2479
2481
  const DRAW_STORE_VERSION = 1;
2480
2482
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2481
2483
  const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2482
- const EMPTY_PAYLOAD$1 = {
2484
+ const EMPTY_PAYLOAD$2 = {
2483
2485
  enabled: null,
2484
2486
  modelId: null,
2485
2487
  version: null
@@ -2578,7 +2580,7 @@ function createFileDrawStore(options = {}) {
2578
2580
  * 读当前 payload。
2579
2581
  * @returns {Promise<DrawPayload>}
2580
2582
  */
2581
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2583
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$2;
2582
2584
  /**
2583
2585
  * 读-改-写一份补丁,**整段排队**。
2584
2586
  *
@@ -2836,7 +2838,7 @@ function switchSource(panel) {
2836
2838
  const USAGE_PREFS_VERSION = 1;
2837
2839
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2838
2840
  const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
2839
- const EMPTY_PAYLOAD = {
2841
+ const EMPTY_PAYLOAD$1 = {
2840
2842
  windowDays: null,
2841
2843
  version: null
2842
2844
  };
@@ -2955,7 +2957,7 @@ function createFileUsagePrefsStore(options = {}) {
2955
2957
  * 读当前 payload。
2956
2958
  * @returns {Promise<UsagePrefsPayload>}
2957
2959
  */
2958
- const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2960
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2959
2961
  /**
2960
2962
  * 读-改-写,**整段排队**。
2961
2963
  *
@@ -3014,6 +3016,258 @@ function createFileUsagePrefsStore(options = {}) {
3014
3016
  };
3015
3017
  }
3016
3018
 
3019
+ //#endregion
3020
+ //#region src/host/roster-seen-store.ts
3021
+ /**
3022
+ * 「本机第一次见到这个模型是什么时候」——胶囊那句「· N 新模型」的唯一事实源。
3023
+ *
3024
+ * ## 它回答什么,不回答什么
3025
+ *
3026
+ * 只记**首见时间戳**:每个 id 第一次出现在本机拉到的目录里的那一刻。由此派生的
3027
+ * 那句话必须说成「本机首次见到的模型」,**不能**说成「网关新增」——上游的
3028
+ * `/v1/models` 不提供「上架时间」,本插件没有任何字段能证实某个模型是这周才上
3029
+ * 的。能证实的只有「本机从哪天开始看见它」,那就只说这一句。
3030
+ *
3031
+ * 也**不**记录活动/促销变化(限免、错峰、签到):那些是实时读数,面板的余额块与
3032
+ * 胶囊 popover 已经直接画上游给的现值,再造一本「促销 diff 账」就是同一事实两本
3033
+ * 账,两本迟早说两套话(ADR-017 的口径)。
3034
+ *
3035
+ * ## 一次调用同时 diff 与落盘
3036
+ *
3037
+ * {@link RosterSeenStore.note} 不能拆成「先 diff 缓存、稍后再写」:那样两个并发
3038
+ * 的收敛轮可能都基于同一份旧 map 判断,后写的那次会把先写的那批模型再宣布一遍。
3039
+ * 读、算、写在一个调用里完成。
3040
+ *
3041
+ * ## 首次写入是**播种**,不是宣布
3042
+ *
3043
+ * 升级到带本文件的构建时,磁盘上没有历史——此时**知道**目录里有这些模型,但
3044
+ * **不知道**其中哪些是新的。把整个目录宣布成「刚上架」正是本文件要避免的谎,
3045
+ * 所以播种那一轮把每个 id 都盖成「已过期」的戳(`now - 窗口`),`newIds` 返回
3046
+ * 空。这个自愈是刻意的:某个 id 要先离开目录再回来,才配得到一个真实的首见戳。
3047
+ *
3048
+ * ## 200 个 id 以上是**形状漂移**,不是「保留最新 200」
3049
+ *
3050
+ * 按「保留最新」淘汰一个仍在目录里的 id,会让它下一轮看起来像没见过 → 重新盖戳
3051
+ * → 再被宣布一次;而被淘汰的集合每轮轮换,于是「新模型」计数会**震荡并变大**
3052
+ * (实测 250 个 id 对 200 上限:50 → 100 → …)。会自己发明新模型的历史文件比
3053
+ * 没有历史文件更糟,所以超限一律读作形状漂移:**不写、不宣布**,旧 map 原样
3054
+ * 保留,标记在异常期间消失,目录回到合理规模后自愈。
3055
+ *
3056
+ * ## 分段与 legacy
3057
+ *
3058
+ * 落点与 provider / draw / usage-prefs 同一套分段规则:
3059
+ * `$DSH_HOME/state/<profile>/<plugin>/roster-seen.json`(无 profile 时落共享目录)。
3060
+ * **不搬**:本文件晚于 profile 分段引入,共享目录里不存在它的旧布局可继承——
3061
+ * 与 usage-prefs-store 的「legacy 继承」相反,这里没有 legacy 缝,也不该长出一条。
3062
+ *
3063
+ * 完整性三件套与其余 store 同源(全部上收在 `state-store.ts`,本文件只做调用方,
3064
+ * 论述见 `draw-store.ts` 头注):写串行化({@link createStateWriteQueue})、写守卫
3065
+ * ({@link guardStateWrite},ADR-006)、读侧说明({@link stateVersionNote})。
3066
+ *
3067
+ * doctor 覆盖:{@link describeRosterSeenPayload} 给只读速览,`doctor.ts` 据此把
3068
+ * roster-seen.json 的「损坏 / 未知版本」报进症状表。
3069
+ *
3070
+ * @module dsh-connect-modelscope-token-plan/roster-seen-store
3071
+ */
3072
+ /** 本 store 落盘 payload 的版本。 */
3073
+ const ROSTER_SEEN_VERSION = 1;
3074
+ /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
3075
+ const KNOWN_ROSTER_SEEN_VERSIONS = Object.freeze([1]);
3076
+ /**
3077
+ * 「新」的时效窗口:首见戳落在这个窗口内就算新。
3078
+ *
3079
+ * 7 天是有意的尺度——它要盖住「用户几天没开面板、回来时发现多了两个模型」这个
3080
+ * 真实场景,又不能长到让一次批量上架在半个月后还挂着标记。窗口在这里是**常量**
3081
+ * 而不是配置:一个能被调成 365 天的「新模型」标记等于没有语义。
3082
+ */
3083
+ const ROSTER_SEEN_NEW_WINDOW_MS = 6048e5;
3084
+ /**
3085
+ * 目录规模上限。超过即视为形状漂移(不是淘汰队列,理由见模块头注)。
3086
+ *
3087
+ * 取值与姊妹插件的同类实现一致:真实目录在数百个模型以内,200 给「这一侧目录
3088
+ * 突然返回了整站列表」这类事故留出足够间距。
3089
+ */
3090
+ const ROSTER_SEEN_MAX_IDS = 200;
3091
+ const EMPTY_PAYLOAD = {
3092
+ seen: {},
3093
+ version: null
3094
+ };
3095
+ /**
3096
+ * 本 build 读不读得懂这个版本。
3097
+ *
3098
+ * 与其余 store 的 `isKnown*Version` 同形:**白名单**判据,不与
3099
+ * {@link ROSTER_SEEN_VERSION} 比较——升级后旧号仍要可读。读侧、报告侧问的都是
3100
+ * 这一个函数。
3101
+ * @param {unknown} version - 盘上读到的值(可能根本不是数字)。
3102
+ * @returns {boolean}
3103
+ */
3104
+ function isKnownRosterSeenVersion(version) {
3105
+ return typeof version === "number" && KNOWN_ROSTER_SEEN_VERSIONS.includes(version);
3106
+ }
3107
+ /**
3108
+ * 归一一张首见表:只留「id 是非空字符串、戳是有限正数」的项。
3109
+ *
3110
+ * 丢掉而不是修补坏项是有意的:一个戳为 0 或负数的项会让 `now - stamp` 变成
3111
+ * 几十年,那个 id 于是永远算「新」;宁可让它下次出现时重新盖一个真实戳。
3112
+ * @param {unknown} raw - 盘上读到的 `seen` 值。
3113
+ * @returns {RosterSeen} 归一后的表(可能是空对象)。
3114
+ */
3115
+ function normalizeRosterSeen(raw) {
3116
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
3117
+ const out = {};
3118
+ for (const [id, stamp] of Object.entries(raw)) {
3119
+ if (id === "") continue;
3120
+ if (typeof stamp !== "number" || !Number.isFinite(stamp) || stamp <= 0) continue;
3121
+ out[id] = stamp;
3122
+ }
3123
+ return out;
3124
+ }
3125
+ /** 本 store 的目录(与 provider / draw / usage-prefs 同一套分段规则)。 */
3126
+ function rosterSeenDir(profile, dir) {
3127
+ if (typeof dir === "string" && dir !== "") return dir;
3128
+ return profile ? profileStateDir(name, profile) : stateDir(name);
3129
+ }
3130
+ /**
3131
+ * 从一张首见表里挑出「还算新」的 id,按首见时间升序(最老的在前)。
3132
+ *
3133
+ * 升序是有意的:调用方只展示前几个名字(胶囊 tooltip / popover 的预算),先看到
3134
+ * 的应该是**最早**变成新的那几个——它们离过期最近,也是读者最可能已经见过、
3135
+ * 需要被告知「其实才刚出现」的那批。
3136
+ * @param {RosterSeen} seen - 归一后的首见表。
3137
+ * @param {number} nowMs - 当前时刻。
3138
+ * @returns {string[]} 窗口内的 id 列表。
3139
+ */
3140
+ function freshIdsOf(seen, nowMs) {
3141
+ return Object.entries(seen).filter(([, first]) => nowMs - first < ROSTER_SEEN_NEW_WINDOW_MS).sort((left, right) => left[1] - right[1]).map(([id]) => id);
3142
+ }
3143
+ /**
3144
+ * 文件支撑的首见表。
3145
+ * @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
3146
+ * @returns {object} store 句柄。
3147
+ */
3148
+ function createFileRosterSeenStore(options = {}) {
3149
+ const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
3150
+ const stateDir = rosterSeenDir(profile, dir);
3151
+ const filePath = join(stateDir, "roster-seen.json");
3152
+ /**
3153
+ * 写一份 payload 并落盘。
3154
+ *
3155
+ * ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
3156
+ * 返回拒绝理由(已 degrade 记录),不落笔。
3157
+ * @param {object} body - 要持久化的 JSON 体。
3158
+ * @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
3159
+ */
3160
+ const writePayload = async (body) => {
3161
+ const refusal = await guardStateWrite(filePath, KNOWN_ROSTER_SEEN_VERSIONS, {
3162
+ label: "roster-seen-state",
3163
+ logger
3164
+ });
3165
+ if (refusal !== null) return refusal;
3166
+ await ensureStateDir(stateDir);
3167
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "roster-seen.json") });
3168
+ return null;
3169
+ };
3170
+ /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
3171
+ const writeQueue = createStateWriteQueue();
3172
+ /**
3173
+ * 解析一份路径上的 payload;认不出的版本读作「空表」但**记住**版本号——读侧
3174
+ * 宽容、versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是
3175
+ * ADR-006)。
3176
+ */
3177
+ const parseAt = async (path) => {
3178
+ const source = obj(await readStateJson(path));
3179
+ const version = typeof source.version === "number" ? source.version : null;
3180
+ if (!isKnownRosterSeenVersion(version)) return {
3181
+ seen: {},
3182
+ version
3183
+ };
3184
+ return {
3185
+ seen: normalizeRosterSeen(source.seen),
3186
+ version
3187
+ };
3188
+ };
3189
+ const cache = createStateReadCache(() => parseAt(filePath), { ttlMs });
3190
+ /** 读当前 payload。 */
3191
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
3192
+ return {
3193
+ /**
3194
+ * 记下这一轮的完整目录,并回答「其中哪些还在新窗口内」。
3195
+ *
3196
+ * 一次调用同时 diff 与落盘(理由见模块头注)。首轮写入是**播种**:把每个 id
3197
+ * 盖成已过期的戳、返回空 `newIds`。
3198
+ * @param {readonly string[]} ids - 上游这一轮列出的**完整**目录 id。
3199
+ * @param {number} [nowMs] - 时钟(测试注入)。
3200
+ * @returns {Promise<{newIds: string[]}>} 窗口内的 id,升序;播种那轮为空。
3201
+ */
3202
+ async note(ids, nowMs = Date.now()) {
3203
+ const list = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id !== "") : [];
3204
+ const unique = [...new Set(list)];
3205
+ if (unique.length === 0) return { newIds: [] };
3206
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
3207
+ return writeQueue.enqueue(async () => {
3208
+ const current = await parseAt(filePath);
3209
+ if (current.version !== null && !isKnownRosterSeenVersion(current.version)) return { newIds: [] };
3210
+ const previous = current.seen;
3211
+ const seeded = current.version === null;
3212
+ const next = {};
3213
+ for (const id of unique) {
3214
+ const first = previous[id];
3215
+ next[id] = typeof first === "number" && first > 0 ? Math.min(first, now) : seeded ? now - ROSTER_SEEN_NEW_WINDOW_MS : now;
3216
+ }
3217
+ if (Object.keys(next).length > 200) return { newIds: [] };
3218
+ if (seeded || Object.keys(previous).length !== Object.keys(next).length || Object.keys(next).some((id) => previous[id] !== next[id])) {
3219
+ const refusal = await writePayload({
3220
+ version: 1,
3221
+ seen: next,
3222
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3223
+ });
3224
+ if (refusal !== null) throw new Error(refusal);
3225
+ cache.remember({
3226
+ seen: next,
3227
+ version: 1
3228
+ });
3229
+ }
3230
+ return { newIds: seeded ? [] : freshIdsOf(next, now) };
3231
+ });
3232
+ },
3233
+ /**
3234
+ * 只读:此刻还算「新」的 id(升序)。
3235
+ *
3236
+ * **不写盘**:快照与 provider 路由都是读路径,写只发生在收敛器的 `note`。
3237
+ * 读路径若顺手补戳,一次面板轮询就能把「谁是新模型」这件事改写掉。
3238
+ * @param {number} [nowMs] - 时钟(测试注入)。
3239
+ * @returns {Promise<string[]>}
3240
+ */
3241
+ async fresh(nowMs = Date.now()) {
3242
+ return freshIdsOf((await readPayload()).seen, Number.isFinite(nowMs) ? nowMs : Date.now());
3243
+ },
3244
+ /** 这张表有没有写过(区分「播种」与「真的没有新模型」)。 */
3245
+ async isSet() {
3246
+ return (await readPayload()).version !== null;
3247
+ },
3248
+ /**
3249
+ * 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
3250
+ *
3251
+ * 读侧宽容(未知版本读作空表)的另一半:快照聚合把它转进 `shapeWarnings`,
3252
+ * doctor 报同一份文件。没有它,「首见表被忽略」在面板上就是「没有新模型」。
3253
+ * @returns {Promise<string|null>}
3254
+ */
3255
+ async versionNote() {
3256
+ const version = (await readPayload()).version;
3257
+ if (version === null || isKnownRosterSeenVersion(version)) return null;
3258
+ return stateVersionNote({
3259
+ label: "roster-seen-state",
3260
+ file: filePath,
3261
+ version,
3262
+ known: KNOWN_ROSTER_SEEN_VERSIONS,
3263
+ consequence: "the first-sighting history is being answered as empty, so the pill shows no new-model marker."
3264
+ });
3265
+ },
3266
+ /** 落盘路径(诊断与测试用)。 */
3267
+ filePath
3268
+ };
3269
+ }
3270
+
3017
3271
  //#endregion
3018
3272
  //#region src/host/draw.ts
3019
3273
  /**
@@ -3948,11 +4202,10 @@ function sleep(ms, signal) {
3948
4202
  /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
3949
4203
  let note = null;
3950
4204
  /**
3951
- * 本次挂载是否真的把工具注册上了。
4205
+ * 此刻工具是否真的在 registry 里。
3952
4206
  *
3953
- * 与 `note` 的区别见文件头:`note` 是「注册尝试过且失败的原因」,这一位是「注册
3954
- * 成功与否」。开关在挂载后才翻开的场景,两者都是「没有缺席原因 + 没注册上」,
3955
- * 只有这一位能把它认出来。
4207
+ * 与 `note` 的区别见文件头:`note` 是「为什么没成」,这一位是「此刻在不在」。
4208
+ * 开关翻转会当场重算这一位,所以它是动态读数,不是「本次挂载」的存档。
3956
4209
  */
3957
4210
  let mounted = false;
3958
4211
  /**
@@ -3998,22 +4251,33 @@ function resetDrawToolNote() {
3998
4251
  //#endregion
3999
4252
  //#region src/host/draw-register.ts
4000
4253
  /**
4001
- * 出图工具的挂载副作用:读开关 → 解析目录 → 注册 agent 工具。
4254
+ * 出图工具的挂载副作用:读开关 → 解析目录 → 注册 agent 工具;开关翻转时**现挂现卸**。
4002
4255
  *
4003
4256
  * 与 `provider-publish.ts` 的分工:那个管「魔搭作为 chat provider 的注册」,本文件
4004
4257
  * 管「给 agent 一个出图工具」。两者是**独立的开关**——回答的是不同问题,合并没有
4005
4258
  * 好处(用户可能想用工具出图但不想把魔搭塞进模型选择器,反之亦然)。
4006
4259
  *
4007
- * 承重约束:**peer 的 tools registry 没有 unregister**。所以
4008
- * - 注册只在挂载时尝试**一次**,不在任何轮询/路由里重复调用;
4009
- * - 开关在挂载后翻转,要到下次 Host 启动才生效(面板文案必须说这句,否则用户
4010
- * 会以为开关坏了);
4260
+ * 承重契约:peer 的 tools.register 返回 disposer(`() => void` 注销句柄,见
4261
+ * `@deepseek-ai/dsh-tools` 的 `ToolsService.register`)。所以
4262
+ * - 开关翻转可以在**本次 Host 生命周期内**生效:`sync()` 读一次开关,要开就注册、
4263
+ * 要关就调 disposer 摘掉,不需要等下次启动;
4264
+ * - 句柄必须**存住**:丢掉它 = 插件卸载后注册残留(僵尸工具),而 registry 对重名
4265
+ * insert 会抛错,下一次挂载直接失败(错误文本形如 `tool "<name>" is already
4266
+ * registered`)。旧版把返回的 `unknown` 直接丢弃,正是这条缺口的来源;
4011
4267
  * - 目录变化**不需要**重注册:工具在**调用时**才读目录(`getEntries` 是个函数),
4012
4268
  * 所以一次目录刷新会自然被下一次出图看到。
4013
4269
  *
4014
- * 失败一律降级为「工具缺席」,绝不炸掉插件:tools 服务缺失、peer 加载失败、
4015
- * registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
4016
- * 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
4270
+ * 并发:`sync()` 串行排队(`chain`),每次执行时**现读**开关,所以快速连点开关
4271
+ * 最终收敛到最后一次保存的值;重复 sync 幂等(已注册就跳过——真去重复注册会撞
4272
+ * registry 的重名保护,把它当正常分支会把「开关开着」误报成缺席)。
4273
+ *
4274
+ * 失败一律降级为「工具缺席」,绝不炸掉插件:tools 服务缺失、peer 加载失败、registry
4275
+ * 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。另有一种**非缺席**的
4276
+ * 降级:registry 收下了却**没返回 disposer**(旧 Host / 旧 peer)。工具能用但关不
4277
+ * 掉,记 `not-removable` 让面板说实话——否则用户关掉开关、面板显示「已关闭」而
4278
+ * 工具还在。
4279
+ *
4280
+ * 本文件不吞错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
4017
4281
  *
4018
4282
  * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
4019
4283
  *
@@ -4031,9 +4295,8 @@ function resetDrawToolNote() {
4031
4295
  /**
4032
4296
  * 解析一个可选服务,允许它稍后才注册。
4033
4297
  *
4034
- * 承 sensenova 同名函数的纪律:**重试的是读,不是注册**——工具不能注销,重复注册
4035
- * 比缺席更糟。这里只做有限次立即读取,不做长退避(服务要么已注册,要么这次挂载
4036
- * 就没有)。
4298
+ * 承 sensenova 同名函数的纪律:**重试的是读,不是写**。这里只做有限次立即读取,
4299
+ * 不做长退避(服务要么已注册,要么这次就没有)。
4037
4300
  * @param {object} ctx - Host 上下文。
4038
4301
  * @param {string} service - 服务名。
4039
4302
  * @param {() => boolean} isDisposed - 卸载闸。
@@ -4049,67 +4312,142 @@ async function resolveService(ctx, service, isDisposed) {
4049
4312
  return null;
4050
4313
  }
4051
4314
  /**
4052
- * 读一次开关,决定是否注册出图工具。
4315
+ * 建一个出图工具挂载控制器(不产生副作用,`sync()` 才读开关/注册)。
4053
4316
  * @param {object} ctx - Host 上下文。
4054
- * @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
4317
+ * @param {object} wiring - `{ settings, configError, tokenStore, inference, drawStore, publisher, logger }`。
4055
4318
  * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`、`pollIntervalMs`)。
4056
- * @returns {Promise<void>}
4319
+ * @returns {DrawToolMount} 控制器。
4057
4320
  */
4058
- async function registerDrawTool(ctx, wiring, deps = {}) {
4321
+ function createDrawToolMount(ctx, wiring, deps = {}) {
4059
4322
  const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
4060
4323
  const loggerFor = logger ?? ctx.logger;
4061
- setDrawToolNote(null);
4062
- setDrawToolMounted(false);
4063
- try {
4064
- if (configError !== null) return;
4065
- const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
4066
- if (resolveSwitchEnabled(panelValue, settings.drawEnabled === true) !== true) return;
4067
- const isDisposed = () => publisher?.isDisposed?.() === true;
4068
- const tools = await resolveService(ctx, "tools", isDisposed);
4069
- if (tools === null || typeof tools.register !== "function") {
4070
- setDrawToolNote("no-tools-service");
4071
- return;
4324
+ /** 当前已注册工具的注销句柄;`null` = 没挂上(或已摘掉)。 */
4325
+ let disposeTool = null;
4326
+ /** peer 的 `defineTool` 只解析一次:成功后就缓存,失败留到下次 sync 重试。 */
4327
+ let defineTool = null;
4328
+ /** 卸载闸:`dispose()` 之后任何在途/后续 sync 都不再注册。 */
4329
+ let closed = false;
4330
+ /** 串行链:保证「读开关 → 挂/卸」不会被两次 sync 交错执行。 */
4331
+ let chain = Promise.resolve();
4332
+ const isDisposed = () => closed || publisher?.isDisposed?.() === true;
4333
+ /**
4334
+ * 摘掉已注册的工具(幂等)。
4335
+ *
4336
+ * 先清注册位再调 disposer:即使 disposer 抛了,面板也不该继续显示「已注册」。
4337
+ * @returns {void}
4338
+ */
4339
+ function unmount() {
4340
+ const dispose = disposeTool;
4341
+ disposeTool = null;
4342
+ setDrawToolMounted(false);
4343
+ if (dispose !== null) try {
4344
+ dispose();
4345
+ } catch (error) {
4346
+ loggerFor?.warn?.(`${name}: draw tool dispose failed (${redactSecrets(errMsg(error))})`);
4072
4347
  }
4073
- let defineTool = null;
4348
+ setDrawToolNote(null);
4349
+ }
4350
+ /**
4351
+ * 解析 peer 的 `defineTool`(成功后缓存)。
4352
+ * @returns {Promise<Function|null>} `defineTool` 或 `null`(失败已记原因)。
4353
+ */
4354
+ async function loadDefineTool() {
4355
+ if (defineTool !== null) return defineTool;
4074
4356
  try {
4075
4357
  const mod = await Promise.resolve(deps.loadToolsModule ? deps.loadToolsModule() : import("@deepseek-ai/dsh-tools"));
4076
- defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
4358
+ const found = mod?.defineTool ?? mod?.default?.defineTool ?? null;
4359
+ if (typeof found === "function") {
4360
+ defineTool = found;
4361
+ return found;
4362
+ }
4077
4363
  } catch (error) {
4078
- setDrawToolNote("peer-load-failed");
4079
4364
  loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
4080
- return;
4081
4365
  }
4082
- if (typeof defineTool !== "function") {
4083
- setDrawToolNote("peer-load-failed");
4084
- return;
4366
+ return null;
4367
+ }
4368
+ /**
4369
+ * 一次收敛:读开关,要开就注册、要关就注销。
4370
+ * @returns {Promise<void>}
4371
+ */
4372
+ async function apply() {
4373
+ if (closed) return;
4374
+ try {
4375
+ if (configError !== null) return;
4376
+ const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
4377
+ if (resolveSwitchEnabled(panelValue, settings.drawEnabled === true) !== true) {
4378
+ unmount();
4379
+ return;
4380
+ }
4381
+ if (disposeTool !== null) {
4382
+ setDrawToolNote(null);
4383
+ setDrawToolMounted(true);
4384
+ return;
4385
+ }
4386
+ const tools = await resolveService(ctx, "tools", isDisposed);
4387
+ if (tools === null || typeof tools.register !== "function") {
4388
+ setDrawToolNote("no-tools-service");
4389
+ setDrawToolMounted(false);
4390
+ return;
4391
+ }
4392
+ const found = await loadDefineTool();
4393
+ if (found === null) {
4394
+ setDrawToolNote("peer-load-failed");
4395
+ setDrawToolMounted(false);
4396
+ return;
4397
+ }
4398
+ const drawFetch = deps.drawFetch ?? ((url, options) => fetch(url, options));
4399
+ const dispose = tools.register(defineDrawTool({
4400
+ defineTool: found,
4401
+ fetchImpl: drawFetch,
4402
+ resolveApiKey: async () => (await tokenStore.resolve()).value,
4403
+ getEntries: async () => {
4404
+ return (await inference.fetchModels().catch(() => null))?.entries ?? [];
4405
+ },
4406
+ getPreferredModelId: async () => {
4407
+ const panelValue = drawStore ? await drawStore.modelId().catch(() => null) : null;
4408
+ return resolveSwitchValue(panelValue, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
4409
+ },
4410
+ settings: {
4411
+ apiBase: settings.apiBase,
4412
+ drawModelId: settings.drawModelId,
4413
+ drawTimeoutMs: settings.drawTimeoutMs,
4414
+ drawWaitMs: settings.drawWaitMs
4415
+ },
4416
+ ...wiring.drawJobs !== void 0 ? { jobs: wiring.drawJobs } : {},
4417
+ ...deps.pollIntervalMs !== void 0 ? { pollIntervalMs: deps.pollIntervalMs } : {},
4418
+ isDisposed
4419
+ }));
4420
+ if (typeof dispose === "function") {
4421
+ disposeTool = dispose;
4422
+ setDrawToolNote(null);
4423
+ setDrawToolMounted(true);
4424
+ } else {
4425
+ disposeTool = null;
4426
+ setDrawToolNote("not-removable");
4427
+ setDrawToolMounted(true);
4428
+ loggerFor?.warn?.(`${name}: draw tool registered without a disposer — turning the switch off cannot unmount it on this host`);
4429
+ }
4430
+ } catch (error) {
4431
+ setDrawToolNote("registry-refused");
4432
+ setDrawToolMounted(false);
4433
+ loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
4085
4434
  }
4086
- const drawFetch = deps.drawFetch ?? ((url, options) => fetch(url, options));
4087
- tools.register(defineDrawTool({
4088
- defineTool,
4089
- fetchImpl: drawFetch,
4090
- resolveApiKey: async () => (await tokenStore.resolve()).value,
4091
- getEntries: async () => {
4092
- return (await inference.fetchModels().catch(() => null))?.entries ?? [];
4093
- },
4094
- getPreferredModelId: async () => {
4095
- const panelValue = drawStore ? await drawStore.modelId().catch(() => null) : null;
4096
- return resolveSwitchValue(panelValue, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
4097
- },
4098
- settings: {
4099
- apiBase: settings.apiBase,
4100
- drawModelId: settings.drawModelId,
4101
- drawTimeoutMs: settings.drawTimeoutMs,
4102
- drawWaitMs: settings.drawWaitMs
4103
- },
4104
- ...wiring.drawJobs !== void 0 ? { jobs: wiring.drawJobs } : {},
4105
- ...deps.pollIntervalMs !== void 0 ? { pollIntervalMs: deps.pollIntervalMs } : {},
4106
- isDisposed
4107
- }));
4108
- setDrawToolMounted(true);
4109
- } catch (error) {
4110
- setDrawToolNote("registry-refused");
4111
- loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
4112
4435
  }
4436
+ return {
4437
+ sync() {
4438
+ chain = chain.then(apply).catch((error) => {
4439
+ loggerFor?.warn?.(`${name}: draw tool sync failed (${redactSecrets(errMsg(error))})`);
4440
+ });
4441
+ return chain;
4442
+ },
4443
+ dispose() {
4444
+ closed = true;
4445
+ unmount();
4446
+ },
4447
+ mounted() {
4448
+ return drawToolMounted();
4449
+ }
4450
+ };
4113
4451
  }
4114
4452
 
4115
4453
  //#endregion
@@ -4920,7 +5258,7 @@ function syncSignaturesAfterPublish(state) {
4920
5258
  * @returns {{reconcile: () => Promise<void>; health: () => {{ lastError: unknown }} }}
4921
5259
  */
4922
5260
  function createProviderReconciler(deps) {
4923
- const { settings, providerStore, inference, publisher, usageEvents, logger } = deps;
5261
+ const { settings, providerStore, inference, publisher, usageEvents, logger, rosterSeenStore } = deps;
4924
5262
  /**
4925
5263
  * 把一次 store 读取收成「值或 null」——**同步抛与异步拒收都降级**。
4926
5264
  *
@@ -5003,6 +5341,11 @@ function createProviderReconciler(deps) {
5003
5341
  return;
5004
5342
  }
5005
5343
  const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
5344
+ if (rosterSeenStore !== void 0) try {
5345
+ await rosterSeenStore.note(entries.map((entry) => String(entry?.id ?? "")));
5346
+ } catch (error) {
5347
+ logger?.warn?.(`${name}: roster first-sighting write failed (${redactSecrets(errMsg(error))}) — the new-model marker may miss this pass`);
5348
+ }
5006
5349
  const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
5007
5350
  const unavailable = await readUnavailable();
5008
5351
  try {
@@ -5116,7 +5459,7 @@ async function soft(promise) {
5116
5459
  }
5117
5460
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
5118
5461
  async function buildSnapshotBody(wiring) {
5119
- const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
5462
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, rosterSeenStore, publisher } = wiring;
5120
5463
  const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
5121
5464
  const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
5122
5465
  const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
@@ -5149,6 +5492,8 @@ async function buildSnapshotBody(wiring) {
5149
5492
  if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
5150
5493
  const windowNote = await soft((async () => usagePrefsStore.versionNote())());
5151
5494
  if (windowNote.ok && windowNote.value !== null) shapeWarnings.push(windowNote.value);
5495
+ const rosterNote = await soft((async () => rosterSeenStore.versionNote())());
5496
+ if (rosterNote.ok && rosterNote.value !== null) shapeWarnings.push(rosterNote.value);
5152
5497
  if (curatedCatalogStale()) {
5153
5498
  const age = curatedCatalogAgeDays();
5154
5499
  shapeWarnings.push(`context-catalog: curated windows were last verified on ${CURATED_AS_OF}` + (age === null ? "" : ` (${age} days ago)`) + " — re-check them against the current catalogue");
@@ -5164,6 +5509,7 @@ async function buildSnapshotBody(wiring) {
5164
5509
  const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
5165
5510
  const contextWindow = await providerStore.contextWindow().catch(() => null);
5166
5511
  const catalogNote = models.ok ? null : `模型目录读不到:${models.error}`;
5512
+ const newModelIds = await rosterSeenStore.fresh().catch(() => []);
5167
5513
  return {
5168
5514
  enabled,
5169
5515
  source,
@@ -5175,6 +5521,7 @@ async function buildSnapshotBody(wiring) {
5175
5521
  allowed: allowedList.mode,
5176
5522
  enabledIds,
5177
5523
  contextWindow,
5524
+ newModelIds,
5178
5525
  roster
5179
5526
  };
5180
5527
  })());
@@ -5410,7 +5757,8 @@ function withOrigin(handler, allowedHosts) {
5410
5757
  */
5411
5758
  /**
5412
5759
  * 注册快照路由。wiring 子集:settings / configError / tokenStore /
5413
- * usageStore / inference / providerStore / publisher / logger。
5760
+ * usageStore / inference / providerStore / drawStore / usagePrefsStore /
5761
+ * rosterSeenStore / publisher / logger。
5414
5762
  * @returns {Function} off() 注销回调。
5415
5763
  */
5416
5764
  function registerSnapshotRoute(ctx, wiring) {
@@ -5699,7 +6047,7 @@ async function readEnabledIds(store, published) {
5699
6047
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
5700
6048
  */
5701
6049
  function registerProviderRoute(ctx, wiring) {
5702
- const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
6050
+ const { settings, providerStore, publisher, reconcile, inference, rosterSeenStore, logger } = wiring;
5703
6051
  /**
5704
6052
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
5705
6053
  * 状态是什么」说两套话。
@@ -5727,6 +6075,7 @@ function registerProviderRoute(ctx, wiring) {
5727
6075
  const roster = rosterWithAvailability(entries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
5728
6076
  const allowedList = parseAllowedList(enabledIds);
5729
6077
  const modelCount = roster.length;
6078
+ const newModelIds = rosterSeenStore === void 0 ? [] : await optional(rosterSeenStore.fresh(), []);
5730
6079
  return {
5731
6080
  ok: true,
5732
6081
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -5739,6 +6088,7 @@ function registerProviderRoute(ctx, wiring) {
5739
6088
  allowed: allowedList.mode,
5740
6089
  enabledIds,
5741
6090
  contextWindow,
6091
+ newModelIds,
5742
6092
  roster,
5743
6093
  ...catalogError !== null ? { catalogError } : {},
5744
6094
  ...extra
@@ -5882,12 +6232,11 @@ function registerProviderRoute(ctx, wiring) {
5882
6232
  * 「给 agent 一个出图工具」。两者的开关、状态文件、候选集合都不同,合并会让
5883
6233
  * 「关掉出图」波及 provider 的注册时序(详见 draw-store.ts 的头注)。
5884
6234
  *
5885
- * 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
5886
- * (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
5887
- * tools registry 没有 unregister,工具一旦注册就摘不下来(完整论述见
5888
- * draw-register.ts 文件头注)。所以本路由保存成功后
5889
- * 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
5890
- * 以为开关坏了。
6235
+ * 开关翻转**当场生效**:保存成功后本路由 await 挂载控制器的 `syncDraw()`(注册
6236
+ * 或注销),再把**收敛之后**的真实状态写进响应。所以面板拿到的不是「已保存、等
6237
+ * 重启」,而是「已挂上」或「挂载失败的原因」。`syncDraw` 由装配层注入(`index.ts`
6238
+ * 把同一个控制器交给路由与挂载),本路由不自己建——否则「注册」与「注销」会各持
6239
+ * 一份状态。
5891
6240
  *
5892
6241
  * peer-free:不 import 任何 Host peer。
5893
6242
  * @module dsh-connect-modelscope-token-plan/routes/draw
@@ -5926,7 +6275,7 @@ async function readEntries(inference) {
5926
6275
  * @returns {Function} 注销回调。
5927
6276
  */
5928
6277
  function registerDrawRoute(ctx, wiring) {
5929
- const { settings, drawStore, inference, logger } = wiring;
6278
+ const { settings, drawStore, inference, syncDraw, logger } = wiring;
5930
6279
  /**
5931
6280
  * 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前状态」说两套话。
5932
6281
  * @param {object} [extra] - 追加/覆盖字段。
@@ -5952,15 +6301,15 @@ function registerDrawRoute(ctx, wiring) {
5952
6301
  effectiveSource: choice.source,
5953
6302
  absentReason: drawToolNote(),
5954
6303
  /**
5955
- * 开关的保存**不会**改变本次挂载的工具。
6304
+ * 开关为开、但工具此刻还没挂上(正在收敛,或收敛失败)。
5956
6305
  *
5957
- * 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
5958
- * 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
5959
- * 判据:开关为开且**本次挂载没注册上**。
6306
+ * 现在开关翻转是**当场**收敛的(见上面的 `syncDraw`),所以正常保存后这一位
6307
+ * 就是 false。它仍然有用:GET 与进程刚启动、还没跑到第一次 sync 时仍可能为
6308
+ * true,面板靠它区分「暂时没挂」与「挂上了」。
5960
6309
  *
5961
- * 注意判据不是「有缺席原因」:缺席原因非 null 表示注册尝试过且失败,而挂载
5962
- * 那一刻开关是关的(用户之后才翻开)时 absentReason 为 null、却同样没注册
5963
- * 上——后者才是最需要这句提示的场景。见 `draw-tool-state.ts` 文件头。
6310
+ * 注意判据不是「有缺席原因」:缺席原因非 null 表示收敛已尝试且失败(面板会
6311
+ * 优先显示那个原因),而「还没跑完」时 absentReason 为 null。见
6312
+ * `draw-tool-state.ts` 文件头。
5964
6313
  */
5965
6314
  mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolMounted() !== true,
5966
6315
  preferredInCatalog: preferredModelId === null ? null : catalog.ok ? candidates.some((row) => row.id === preferredModelId) : null,
@@ -6010,6 +6359,7 @@ function registerDrawRoute(ctx, wiring) {
6010
6359
  try {
6011
6360
  if (hasEnabled) await drawStore.saveEnabled(body.enabled);
6012
6361
  if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
6362
+ if (hasEnabled) await syncDraw?.();
6013
6363
  } catch (error) {
6014
6364
  respondError(response, error);
6015
6365
  return;
@@ -6031,6 +6381,7 @@ function registerDrawRoute(ctx, wiring) {
6031
6381
  }));
6032
6382
  try {
6033
6383
  await drawStore.reset();
6384
+ await syncDraw?.();
6034
6385
  } catch (error) {
6035
6386
  respondError(response, error);
6036
6387
  return;
@@ -6213,7 +6564,7 @@ function registerRoutes(ctx, wiring) {
6213
6564
  *
6214
6565
  * **出图工具**
6215
6566
  * - `draw.ts` 协议纯逻辑半边(peer-free)
6216
- * - `draw-register.ts` 工具注册(挂载时一次性,不可撤销)
6567
+ * - `draw-register.ts` 工具挂载控制器(开关翻转现挂现卸)
6217
6568
  * - `draw-runtime.ts` 运行时(两段式:提交 → 轮询)
6218
6569
  * - `draw-jobs.ts` 作业表(本次挂载内有效)
6219
6570
  * - `draw-tool-state.ts` 缺席原因 + 本次挂载注册位(内存)
@@ -6272,6 +6623,10 @@ function apply(ctx, config = {}, deps = {}) {
6272
6623
  profile,
6273
6624
  logger: ctx.logger
6274
6625
  });
6626
+ const rosterSeenStore = createFileRosterSeenStore({
6627
+ profile,
6628
+ logger: ctx.logger
6629
+ });
6275
6630
  const publisher = createProviderPublisher({
6276
6631
  settings,
6277
6632
  panelSwitch: () => providerStore.enabled(),
@@ -6294,6 +6649,7 @@ function apply(ctx, config = {}, deps = {}) {
6294
6649
  inference,
6295
6650
  publisher,
6296
6651
  usageEvents: () => usageStore.events(),
6652
+ rosterSeenStore,
6297
6653
  logger: ctx.logger
6298
6654
  });
6299
6655
  const wiring = {
@@ -6306,16 +6662,21 @@ function apply(ctx, config = {}, deps = {}) {
6306
6662
  drawStore,
6307
6663
  drawJobs,
6308
6664
  usagePrefsStore,
6665
+ rosterSeenStore,
6309
6666
  publisher,
6310
6667
  reconcile,
6311
6668
  logger: ctx.logger
6312
6669
  };
6313
- const offs = registerRoutes(ctx, wiring);
6670
+ const drawMount = createDrawToolMount(ctx, wiring, deps);
6671
+ const offs = registerRoutes(ctx, {
6672
+ ...wiring,
6673
+ syncDraw: () => drawMount.sync()
6674
+ });
6314
6675
  ctx.effect(() => {
6315
6676
  reconcile().catch((error) => {
6316
6677
  ctx.logger?.warn?.(`${name}: mount reconcile failed (${redactSecrets(errMsg(error))})`);
6317
6678
  });
6318
- registerDrawTool(ctx, wiring, deps);
6679
+ drawMount.sync();
6319
6680
  const timer = setInterval(() => {
6320
6681
  reconcile().catch((error) => {
6321
6682
  ctx.logger?.warn?.(`${name}: scheduled reconcile failed (${redactSecrets(errMsg(error))})`);
@@ -6323,6 +6684,7 @@ function apply(ctx, config = {}, deps = {}) {
6323
6684
  }, settings.pollSeconds * 1e3);
6324
6685
  return () => {
6325
6686
  clearInterval(timer);
6687
+ drawMount.dispose();
6326
6688
  resetDrawToolNote();
6327
6689
  drawJobs.dispose();
6328
6690
  publisher.dispose();