dsh-model-switch 0.4.36 → 0.4.38

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,24 @@
1
+ ## v0.4.38
2
+
3
+ 计划卡的「设置为目标并执行」现在会**真的顶掉**旧目标:宿主把 `GoalError` 经 RPC 网关中转成 `gateway/internal`,而插件只认 `GOAL_ALREADY_EXISTS`,于是 get→clear→create 的替换分支一直是死代码,点一次报一次「设置目标失败」。
4
+
5
+ - **P0 root cause: the conflict was matched on a code the gateway never sends (`src/picker/plan-goal.ts`).** `@deepseek-ai/dsh-goal` 的 `create()` 对任何非 `complete` 的现存目标 `throw new GoalError('goal "…" already exists with phase "…"', 'GOAL_ALREADY_EXISTS')`;网关把它转成 `{ code: 'gateway/internal', message: <原文> }`。插件比较的是 `error.code === 'GOAL_ALREADY_EXISTS'`,永远不成立,替换流程从未执行——用户看到的就是 `设置目标失败:gateway/internal: goal "goal-32ce…" already exists with phase "paused"`(该会话目标卡在 paused)。已核对内核实现:`clear(agent, ref)` 只做 id+revision 的 compare-and-set,**不限制 phase**,所以 active/paused/blocked 都能被顶替;`create` 只放过 `complete`。
6
+ - **Fix 1: recognise both refusal shapes.** `isAlreadyExistsError()` 先认域码,再用 `/already exists with phase/i` 识别网关转述的文本。只有这条消息才允许清旧目标——其它 `gateway/internal` 拒绝(如 agent 不存活)一律不碰现有目标,用例钉死。
7
+ - **Fix 2: transport failures no longer escape.** `create` / `get` / `clear` 全部经 `guarded()`:被 reject 的调用转成 `{ code: 'GOAL_TRANSPORT', message }` 的拒绝信封,不再冒泡成未处理异常(否则卡片丢掉错误态、计划停在待审却没有解释)。
8
+ - **Fix 3: readable messages.** `normalizeHostMessage()` 剥掉 `gateway/xxx:` 前缀,`failure()` 丢弃传输层码,卡片不会再出现双重前缀;失败信息在源头就干净,卡片无需改动。
9
+ - **Tests.** `tests/picker/plan-goal.spec.ts` 新增 6 例:网关形状的 paused / blocked 替换、非冲突网关拒绝不清目标、create / get / clear 三处 reject。`pnpm run check` 全绿(56 测试文件 / **510** 测试 + pack 闸门)。
10
+
11
+ ## v0.4.37
12
+
13
+ 「能力路由」四张卡片现在说明自己的层级:摘要行是**默认(无组时)**的值,另起一行列出所有覆盖它的供应商配置组,改完组不再看起来像"没生效"。
14
+
15
+ - **纯展示改动,解析行为不变。** `resolveCapabilityRoute` 仍是「组 ?? 默认」(`src/session-route.ts`):压缩 / 自动审查 / 子代理是单次静态解析、不做失败回落,会话命名仍走既有候选链(组对 → 默认对 → 行自身对)。
16
+ - **新增 `capabilityGroupPins(groups, capability)`(`src/session-route.ts`)**:按 provider 排序收集"固定了该能力"的组;组内空串或半对一律视为未固定,因此 `titleProvider: ""`(跟随会话模型)不会被误当成固定值。
17
+ - **卡片新增一行 `note`(`src/client/ModelSwitchSettings.tsx`)**:子代理、自动审查、压缩、会话命名四处;≤3 个组时全列(`组覆盖:apigoto → apigoto · gpt-6-luna;…`),超过 3 个折叠为「… 等 N 个组」;无组覆盖时**不渲染**,与旧版输出一致。子代理行额外带上组里的 `subagentReasoningEffort`。
18
+ - **文案**:`src/client/locales.ts` 新增 `groupOverridesLabel` / `groupOverridesMore`(zh + en)。
19
+ - **测试**:`tests/capability-group-pins.spec.ts` 四例(provider 排序 / 子代理 effort / 空串跳过 / 无组表)。`pnpm run check` 绿(57 文件 / **508** 测试 + pack 闸门)。
20
+ - **Lab 验证(正式版 0.2.1-rc.2,`DSH_HOME=~/.dsh-lab`,3082,released artifact)**:注入一个 `deepseek-account` 组后打开「设置 → 模型切换」,子代理/自动审查/压缩三张卡片出现 `组覆盖:deepseek-account → deepseek-account · deepseek-flash;无组时用上方默认值`,会话命名因组内留空**不出现**;随后把组里自动审查与子代理两项清空,重载后组覆盖行从 3 条降为 1 条(只剩压缩)。改动在运行时重组后立即反映,无需重启。
21
+
1
22
  ## v0.4.36
2
23
 
3
24
  「自动审查」不再在插件安装类重组后停在「完全权限」:Auto 选择改为**持续记录**,回放只消费真正恢复掉的会话。
@@ -6,7 +27,7 @@
6
27
  - **Fix 1: a continuous record that survives reordering.** Every session's `permission/preset` feed is wired to the record as the session is seen; a change that arrives while this row has released the registration is the official teardown flip (not a user choice) and keeps its id recorded, anything else removes it. Release-time `persistAutoSnapshot()` now merges the live read **into** that record instead of replacing it, so an empty or failed read can no longer destroy the selection.
7
28
  - **Fix 2: a replay consumes only what it restored (`src/auto-sessions-state.ts`).** `planAutoSnapshotReplay()` splits the recorded ids into the ones a live session claims and the rest; the rest stay in the file with their **original** `savedAt` — an early claim cannot eat the selection of sessions that load later, and the 15-minute replay window is not restarted behind the user's back. `flushAutoSnapshot()` removes the file only once every recorded id has been replayed.
8
29
  - **Tests.** `tests/auto-sessions-state.spec.ts` adds the timestamp-preserving decode, the merge semantics that pin the out-of-order teardown case, the empty-list no-consume case, and a repeated-recompose pass that narrows the record to empty. `pnpm run check` green (56 test files / **504** tests + pack gate).
9
- - **Lab verification (official 0.2.1-rc.2, `DSH_HOME=~/.dsh-lab`, port 3082) — and one gap it exposed, now closed.** The build was installed in both lab profiles and driven against the released `0.4.35` as the A/B: with a session on「自动审查」the plugin was removed and re-added, a new plugin row was inserted through `cordis.patch.yml`, and a bundle-list change forced a recompose. The record half of the fix ran end to end there — a teardown wrote `auto-sessions.json` with the exact session id, the record survived the plugin being absent, and the next mount's replay consumed it. That replay is what the lab caught being incomplete: a claim runs before the web Host has lazily loaded the session, so ids could sit in the file forever with nobody to consume them. The replay now also runs whenever a session is observed, on tool-call activity, and from a bounded 10-second sweep while the record holds ids — the file observed still pending after a plugin swap was consumed by the next session load. The flip itself (`auto` → `danger-full-access`) does not reproduce in the lab — even a full plugin removal leaves the stored preset name untouched there — so that scenario stays covered by the desktop session-log forensics above and the out-of-order teardown tests.
30
+ - **Lab verification (official 0.2.1-rc.2, `DSH_HOME=~/.dsh-lab`, port 3082) — and one gap it exposed, now closed.** The build was installed in both lab profiles and driven against the released `0.4.35` as the A/B: with a session on「自动审查」the plugin was removed and re-added, a new plugin row was inserted through `cordis.patch.yml`, and a bundle-list change forced a recompose. The record half of the fix ran end to end there — a teardown wrote `auto-sessions.json` with the exact session id, the record survived the plugin being absent, and the next mount's replay consumed it. That replay is what the lab caught being incomplete: a claim runs before the web Host has lazily loaded the session, so ids could sit in the file forever with nobody to consume them. The replay now also runs whenever a session is observed, on tool-call activity, and from a bounded 10-second sweep while the record holds ids — the file observed still pending after a plugin swap was consumed by the next session load. The flip itself (`auto` → `danger-full-access`) does not reproduce in the lab — even a full plugin removal leaves the stored preset name untouched there — so that scenario stays covered by the desktop session-log forensics above and the out-of-order teardown tests. Open-item audit of the deletion path: the empty-list delete has **no second loss face** — every caller (`trackAutoSelection`'s explicit un-select, a fully consumed replay, a teardown merge, and the stale/malformed cleanup in `flushAutoSnapshot`) deletes only a record that already decodes to empty. The one theoretical residual is on the *recording* side, not the deletion side: a session that switched to Auto while never observed (listener not yet wired) plus a teardown whose live read fails at the same moment; the observation passes (claim, replay, tool calls, 10-second sweep) bound that window to one sweep interval. Closing it entirely needs a session-load event on the public `sessions` face, which does not exist — recorded for a later upstream proposal, per the repo's public-seam rule.
10
31
 
11
32
  ## v0.4.35
12
33
 
package/lib/client.js CHANGED
@@ -268,7 +268,7 @@ window.__ModuleLoader__.load({
268
268
  });
269
269
  }
270
270
  /** A non-blank string. Blank values behave exactly like an absent field. */
271
- function present(value) {
271
+ function present$1(value) {
272
272
  return typeof value === "string" && value.trim() !== "";
273
273
  }
274
274
  /** Normalize one raw entry to its owning provider and route view; undefined for non-object junk. */
@@ -278,10 +278,10 @@ window.__ModuleLoader__.load({
278
278
  const group = {};
279
279
  for (const field of ROUTE_FIELDS) {
280
280
  const item = source[field];
281
- if (present(item)) group[field] = item;
281
+ if (present$1(item)) group[field] = item;
282
282
  }
283
283
  return {
284
- provider: present(source.provider) ? source.provider : "",
284
+ provider: present$1(source.provider) ? source.provider : "",
285
285
  group
286
286
  };
287
287
  }
@@ -349,6 +349,72 @@ window.__ModuleLoader__.load({
349
349
  return Object.entries(groups).map(([key, value]) => entryOf(key, value));
350
350
  }
351
351
  //#endregion
352
+ //#region src/session-route.ts
353
+ /** Flat deployment-default fields per capability, in group-storage field names. */
354
+ const CAPABILITY_FIELDS = Object.freeze({
355
+ compaction: {
356
+ provider: "compactProvider",
357
+ model: "compactModel"
358
+ },
359
+ autoReview: {
360
+ provider: "autoReviewProvider",
361
+ model: "autoReviewModel"
362
+ },
363
+ title: {
364
+ provider: "titleProvider",
365
+ model: "titleModel"
366
+ },
367
+ subagent: {
368
+ provider: "subagentProvider",
369
+ model: "subagentModel"
370
+ }
371
+ });
372
+ /** A non-blank string; blank values behave exactly like an absent field. */
373
+ function present(value) {
374
+ return typeof value === "string" && value.trim() !== "";
375
+ }
376
+ /** Read one capability's pair from a flat route record; undefined unless both halves are present. */
377
+ function pairOf(source, fields) {
378
+ if (source === void 0) return void 0;
379
+ const record = source;
380
+ const provider = record[fields.provider];
381
+ const model = record[fields.model];
382
+ if (!present(provider) || !present(model)) return void 0;
383
+ return {
384
+ provider,
385
+ model
386
+ };
387
+ }
388
+ /**
389
+ * Every provider group that overrides one capability, provider-sorted.
390
+ *
391
+ * Reads the stored table only and never attributes a session, so a caller can
392
+ * describe the layering ("this is the default; these groups override it")
393
+ * without claiming which layer a given session receives.
394
+ * @param groups - the normalized provider → group map.
395
+ * @param capability - capability whose group pins are collected.
396
+ * @returns The complete pins, ordered by provider id; empty when no group pins it.
397
+ */
398
+ function capabilityGroupPins(groups, capability) {
399
+ const pins = [];
400
+ if (groups === void 0) return pins;
401
+ for (const provider of Object.keys(groups).sort()) {
402
+ const group = groups[provider];
403
+ const route = pairOf(group, CAPABILITY_FIELDS[capability]);
404
+ if (route === void 0) continue;
405
+ const effort = capability === "subagent" ? group?.subagentReasoningEffort : void 0;
406
+ pins.push(present(effort) ? {
407
+ provider,
408
+ route,
409
+ reasoningEffort: effort
410
+ } : {
411
+ provider,
412
+ route
413
+ });
414
+ }
415
+ return pins;
416
+ }
417
+ //#endregion
352
418
  //#region src/client/plugin-rpc.ts
353
419
  function callPluginRpc(rpc, method, endpoint, payload, signal) {
354
420
  return rpc.call("/api", method, {
@@ -657,6 +723,21 @@ window.__ModuleLoader__.load({
657
723
  function compact(...values) {
658
724
  return values.filter((value) => value !== void 0 && value !== "").join(" · ");
659
725
  }
726
+ /**
727
+ * The layering line under a capability card.
728
+ *
729
+ * The card's own summary is the deployment-wide default ("默认(无组时)"); a
730
+ * provider group overrides it for that provider's sessions, so name the pins
731
+ * instead of letting the default look like the effective route.
732
+ * @param t - settings locale lookup.
733
+ * @param pins - the capability's provider-group pins.
734
+ * @returns The line, or undefined when no group pins this capability.
735
+ */
736
+ function groupNote(t, pins) {
737
+ if (pins.length === 0) return void 0;
738
+ const listed = pins.slice(0, 3).map((pin) => `${pin.provider} → ${pin.route.provider} · ${pin.route.model}${pin.reasoningEffort === void 0 ? "" : ` · ${pin.reasoningEffort}`}`).join(";");
739
+ return pins.length > 3 ? t("groupOverridesMore").replace("{listed}", listed).replace("{count}", String(pins.length)) : t("groupOverridesLabel").replace("{pins}", listed);
740
+ }
660
741
  /** A complete pinned pair; either half blank leaves the capability at its default. */
661
742
  function draftPair(provider, model) {
662
743
  return provider !== void 0 && provider.trim() !== "" && model !== void 0 && model.trim() !== "" ? {
@@ -811,7 +892,7 @@ window.__ModuleLoader__.load({
811
892
  });
812
893
  return null;
813
894
  }
814
- function RouteCard({ title, summary, hint, icon, open, onToggle, disabled = false, badge, badgeWarn = false, trailing, expandable = true, children }) {
895
+ function RouteCard({ title, summary, hint, note, icon, open, onToggle, disabled = false, badge, badgeWarn = false, trailing, expandable = true, children }) {
815
896
  const header = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
816
897
  type: "button",
817
898
  className: ModelSwitchSettings_module_css_default.routeHeader,
@@ -837,6 +918,10 @@ window.__ModuleLoader__.load({
837
918
  className: ModelSwitchSettings_module_css_default.routeSummary,
838
919
  children: summary
839
920
  }),
921
+ note === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
922
+ className: ModelSwitchSettings_module_css_default.hint,
923
+ children: note
924
+ }),
840
925
  hint === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
841
926
  className: ModelSwitchSettings_module_css_default.hint,
842
927
  children: hint
@@ -1068,6 +1153,12 @@ window.__ModuleLoader__.load({
1068
1153
  };
1069
1154
  }, [loadSearchCapabilities]);
1070
1155
  const providerGroups = normalizeProviderGroups(switchSettings.value?.providerGroups);
1156
+ const groupPins = {
1157
+ autoReview: capabilityGroupPins(providerGroups, "autoReview"),
1158
+ compaction: capabilityGroupPins(providerGroups, "compaction"),
1159
+ title: capabilityGroupPins(providerGroups, "title"),
1160
+ subagent: capabilityGroupPins(providerGroups, "subagent")
1161
+ };
1071
1162
  /**
1072
1163
  * Seed an unsaved group from the live routes.
1073
1164
  *
@@ -1556,6 +1647,7 @@ window.__ModuleLoader__.load({
1556
1647
  title: props.t("subagent"),
1557
1648
  summary: subagentSummary,
1558
1649
  hint: props.t("subagentHelp"),
1650
+ note: groupNote(props.t, groupPins.subagent),
1559
1651
  icon: "subagent",
1560
1652
  open: open === "subagent" && subagentOn,
1561
1653
  onToggle: () => {
@@ -1785,6 +1877,7 @@ window.__ModuleLoader__.load({
1785
1877
  title: props.t("autoReview"),
1786
1878
  summary: autoReviewDraft === void 0 ? props.t("loading") : autoReviewOn ? routeName(groups, autoReviewDraft) : props.t("autoReviewFollow"),
1787
1879
  hint: props.t("autoReviewHelp"),
1880
+ note: groupNote(props.t, groupPins.autoReview),
1788
1881
  icon: "autoReview",
1789
1882
  open: open === "autoReview" && autoReviewOn,
1790
1883
  onToggle: () => {
@@ -1853,6 +1946,7 @@ window.__ModuleLoader__.load({
1853
1946
  title: props.t("compaction"),
1854
1947
  summary: compactionDraft === void 0 ? props.t("loading") : compactionOn ? routeName(groups, compactionDraft) : props.t("compactionFollow"),
1855
1948
  hint: props.t("compactionHelp"),
1949
+ note: groupNote(props.t, groupPins.compaction),
1856
1950
  icon: "compaction",
1857
1951
  open: open === "compaction" && compactionOn,
1858
1952
  onToggle: () => {
@@ -1917,6 +2011,7 @@ window.__ModuleLoader__.load({
1917
2011
  title: props.t("sessionTitle"),
1918
2012
  summary: titleDraft === void 0 ? props.t("loading") : titleOn ? routeName(groups, titleDraft) : props.t("titleFollow"),
1919
2013
  hint: props.t("titleHelp"),
2014
+ note: groupNote(props.t, groupPins.title),
1920
2015
  icon: "sessionTitle",
1921
2016
  open: open === "sessionTitle" && titleOn,
1922
2017
  onToggle: () => {
@@ -2307,6 +2402,8 @@ window.__ModuleLoader__.load({
2307
2402
  providerGroupsBodyHelp: "每个供应商保存一组压缩、自动审查、会话命名与子代理路由。会话自身的供应商与该组一致时,这四项能力按组实时解析,未固定项回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话并行时互不影响,会话中切换供应商后下一次调用也会立即跟随新供应商的配置组。",
2308
2403
  providerGroupPick: "选择供应商以编辑其配置组。",
2309
2404
  providerGroupDelete: "删除该组",
2405
+ groupOverridesLabel: "组覆盖:{pins};无组时用上方默认值",
2406
+ groupOverridesMore: "组覆盖:{listed} 等 {count} 个组;无组时用上方默认值",
2310
2407
  saveTimeout: "保存超时,请重试",
2311
2408
  saveRejected: "保存被拒绝,请重试",
2312
2409
  "reason.central-subagent-routing": "Alpha.4 没有全局子代理启动路由接口。",
@@ -2367,6 +2464,8 @@ window.__ModuleLoader__.load({
2367
2464
  providerGroupsBodyHelp: "Each provider stores one route group for compaction, auto review, session naming, and subagents. A session running on that provider resolves all four capabilities from the group and falls back to the cards above when a row is off. Switching a session’s model writes no global setting, so parallel sessions on different providers never affect each other, and mid-session provider switches take effect on the very next call.",
2368
2465
  providerGroupPick: "Pick a provider to edit its group.",
2369
2466
  providerGroupDelete: "Delete group",
2467
+ groupOverridesLabel: "Group overrides: {pins}; without a group the default above applies",
2468
+ groupOverridesMore: "Group overrides: {listed} and {count} groups; without a group the default above applies",
2370
2469
  saveTimeout: "The save timed out; try again",
2371
2470
  saveRejected: "The save was rejected; try again",
2372
2471
  "reason.central-subagent-routing": "Alpha.4 exposes no global Subagent start-routing seam.",
@@ -3884,15 +3983,68 @@ window.__ModuleLoader__.load({
3884
3983
  }
3885
3984
  //#endregion
3886
3985
  //#region src/picker/plan-goal.ts
3986
+ /**
3987
+ * Strip the gateway's transport prefix from a Host message.
3988
+ *
3989
+ * The goal service throws `GoalError`, and the RPC gateway relays a thrown
3990
+ * error as `code: "gateway/internal"` with the original text still in
3991
+ * `message`. Showing `gateway/internal: goal "…" already exists…` to a human
3992
+ * buries the only sentence that matters, so the prefix is removed here.
3993
+ */
3994
+ function normalizeHostMessage(message) {
3995
+ return message.replace(/^\s*gateway\/[a-z0-9-]+\s*:\s*/i, "").trim();
3996
+ }
3997
+ /**
3998
+ * Whether a refused goal call means "this session already has a goal".
3999
+ *
4000
+ * Two shapes reach here. A Host that returns the domain code says
4001
+ * `GOAL_ALREADY_EXISTS`; the gateway instead relays the thrown `GoalError` as
4002
+ * `gateway/internal` and keeps only the text, so the message itself has to be
4003
+ * recognised. The domain code is checked first and the text only decides the
4004
+ * message shape the Host is known to produce — a refusal for any other reason
4005
+ * must never clear the user's current goal.
4006
+ */
4007
+ function isAlreadyExistsError(error) {
4008
+ if (error.code === "GOAL_ALREADY_EXISTS") return true;
4009
+ return /already exists with phase/i.test(error.message);
4010
+ }
4011
+ /** Codes that describe the transport, not the goal domain. */
4012
+ function isTransportCode(code) {
4013
+ return code === "GOAL_TRANSPORT" || code.startsWith("gateway/");
4014
+ }
3887
4015
  function failure(result, fallback) {
3888
4016
  if (result.ok) return { message: fallback };
3889
4017
  const { code, message } = result.error;
3890
- return {
4018
+ const text = normalizeHostMessage(message);
4019
+ const wording = text === "" ? fallback : text;
4020
+ return isTransportCode(code) ? { message: wording } : {
3891
4021
  code,
3892
- message: message.trim() === "" ? fallback : message
4022
+ message: wording
3893
4023
  };
3894
4024
  }
3895
4025
  /**
4026
+ * Run one remote call, turning a rejected promise into a refused envelope.
4027
+ *
4028
+ * Generated remotes normally resolve `{ ok: false }`, but a transport failure
4029
+ * (or an older Host that rejects instead of answering) must not escape as an
4030
+ * unhandled rejection: the card would lose its error state and the plan would
4031
+ * stay pending with no explanation.
4032
+ */
4033
+ async function guarded(call, fallback) {
4034
+ try {
4035
+ return await call();
4036
+ } catch (error) {
4037
+ const text = normalizeHostMessage(error instanceof Error ? error.message : String(error));
4038
+ return {
4039
+ ok: false,
4040
+ error: {
4041
+ code: "GOAL_TRANSPORT",
4042
+ message: text === "" ? fallback : text
4043
+ }
4044
+ };
4045
+ }
4046
+ }
4047
+ /**
3896
4048
  * Address the plan document exactly as the official opener does.
3897
4049
  *
3898
4050
  * A logged plan (`callId` present) resolves from `dsh-resource://plan/...`
@@ -3935,26 +4087,26 @@ window.__ModuleLoader__.load({
3935
4087
  message: "goal objective must be a non-empty string"
3936
4088
  };
3937
4089
  const objective = `持续推进当前plan直到完成(plan 文档:${planDocumentAddress(sessionId, ref)})`;
3938
- const created = await remote.create(sessionId, { objective });
4090
+ const created = await guarded(() => remote.create(sessionId, { objective }), "the goal could not be created");
3939
4091
  if (created.ok) return { kind: "created" };
3940
- if (created.error.code !== "GOAL_ALREADY_EXISTS") return {
4092
+ if (!isAlreadyExistsError(created.error)) return {
3941
4093
  kind: "failed",
3942
4094
  ...failure(created, "the goal could not be created")
3943
4095
  };
3944
- const current = await remote.get(sessionId);
4096
+ const current = await guarded(() => remote.get(sessionId), "the current goal could not be read for replacement");
3945
4097
  if (!current.ok || current.value === void 0 || current.value === null) return {
3946
4098
  kind: "failed",
3947
4099
  ...failure(current, "the current goal could not be read for replacement")
3948
4100
  };
3949
- const cleared = await remote.clear(sessionId, {
4101
+ const cleared = await guarded(() => remote.clear(sessionId, {
3950
4102
  id: current.value.id,
3951
4103
  revision: current.value.revision
3952
- });
4104
+ }), "the current goal could not be cleared");
3953
4105
  if (!cleared.ok) return {
3954
4106
  kind: "failed",
3955
4107
  ...failure(cleared, "the current goal could not be cleared")
3956
4108
  };
3957
- const retry = await remote.create(sessionId, { objective });
4109
+ const retry = await guarded(() => remote.create(sessionId, { objective }), "the goal could not be created");
3958
4110
  if (retry.ok) return { kind: "replaced" };
3959
4111
  return {
3960
4112
  kind: "failed",
@@ -49,6 +49,8 @@ export declare const zh: {
49
49
  readonly providerGroupsBodyHelp: "每个供应商保存一组压缩、自动审查、会话命名与子代理路由。会话自身的供应商与该组一致时,这四项能力按组实时解析,未固定项回落到上方卡片里的全局设置。切换某个会话的模型不会写入任何全局设置,因此不同供应商的多个会话并行时互不影响,会话中切换供应商后下一次调用也会立即跟随新供应商的配置组。";
50
50
  readonly providerGroupPick: "选择供应商以编辑其配置组。";
51
51
  readonly providerGroupDelete: "删除该组";
52
+ readonly groupOverridesLabel: "组覆盖:{pins};无组时用上方默认值";
53
+ readonly groupOverridesMore: "组覆盖:{listed} 等 {count} 个组;无组时用上方默认值";
52
54
  readonly saveTimeout: "保存超时,请重试";
53
55
  readonly saveRejected: "保存被拒绝,请重试";
54
56
  readonly 'reason.central-subagent-routing': "Alpha.4 没有全局子代理启动路由接口。";
@@ -77,6 +77,28 @@ export declare function subagentRouteOf(settings: RouteBearingSettings | undefin
77
77
  * @returns The complete group pair, or undefined when the group pins nothing here.
78
78
  */
79
79
  export declare function groupRoute(settings: RouteBearingSettings | undefined, provider: string | undefined, capability: CapabilityId): CapabilityRoute | undefined;
80
+ /**
81
+ * One provider group's pin for one capability, as the settings page shows it.
82
+ */
83
+ export interface CapabilityGroupPin {
84
+ /** The provider whose sessions this group applies to. */
85
+ readonly provider: string;
86
+ /** The pinned route. */
87
+ readonly route: CapabilityRoute;
88
+ /** A subagent spawn's effort; only meaningful for `subagent`. */
89
+ readonly reasoningEffort?: string;
90
+ }
91
+ /**
92
+ * Every provider group that overrides one capability, provider-sorted.
93
+ *
94
+ * Reads the stored table only and never attributes a session, so a caller can
95
+ * describe the layering ("this is the default; these groups override it")
96
+ * without claiming which layer a given session receives.
97
+ * @param groups - the normalized provider → group map.
98
+ * @param capability - capability whose group pins are collected.
99
+ * @returns The complete pins, ordered by provider id; empty when no group pins it.
100
+ */
101
+ export declare function capabilityGroupPins(groups: ProviderGroups | undefined, capability: CapabilityId): readonly CapabilityGroupPin[];
80
102
  /**
81
103
  * The route one capability should use for one session.
82
104
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-switch",
3
- "version": "0.4.36",
3
+ "version": "0.4.38",
4
4
  "description": "Explicit model routing plus composer/Plan picker for DeepSeek Harness",
5
5
  "repository": {
6
6
  "type": "git",