@ljwei-stak/dsh-model-router 0.16.1 → 0.16.3

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.
@@ -1876,33 +1876,72 @@ function ModelProfileEditor({ routes, settingsScope, onSaved }) {
1876
1876
  } }, "\u5220\u9664\u6B64\u6A21\u578B\u914D\u7F6E")), notice && /* @__PURE__ */ import_react.default.createElement("p", { className: notice.tone === "error" ? "mr-error" : "mr-profile-success", role: notice.tone === "error" ? "alert" : "status" }, notice.text), /* @__PURE__ */ import_react.default.createElement("p", { className: "mr-caption" }, "\u8D28\u91CF\u8BC4\u5206\u548C\u4EF7\u683C\u90FD\u662F\u7528\u6237\u63D0\u4F9B\u7684\u4F30\u503C\u3002\u672A\u586B\u5199\u5355\u4EF7\u65F6\u663E\u793A\u201C\u4EF7\u683C\u5F85\u914D\u7F6E\u201D\uFF1B\u9884\u7B97\u53EA\u5F71\u54CD\u672C\u5730\u89C4\u5212\uFF0C\u4E0D\u9650\u5236\u5B9E\u9645\u8D26\u5355\u3002CLI \u6A21\u578B\u540D\u987B\u4E0E\u5382\u5546\u5DE5\u5177\u6838\u5BF9\u3002\u6267\u884C\u65B9\u5F0F\u51B3\u5B9A\u8BE5\u6A21\u578B\u6536\u5230\u4EFB\u52A1\u65F6\u8D70\u5B98\u65B9\u65E0\u754C\u9762\u5DE5\u5177\u8FD8\u662F\u6A21\u578B\u76EE\u5F55 API\uFF1B\u5B98\u65B9\u5DE5\u5177\u5931\u8D25\u65F6\u4ECD\u4F1A\u56DE\u9000 API\u3002\u8BA1\u8D39\u65B9\u5F0F\u9ED8\u8BA4\u8BA2\u9605\u4F18\u5148\uFF1A\u5148\u7528 CLI \u8D26\u53F7\u767B\u5F55\u6216\u7F16\u7A0B\u5957\u9910 Key \u8DEF\u7EBF\uFF0C\u8BA2\u9605\u989D\u5EA6\u7528\u5C3D\u6216\u9650\u6D41\u65F6\u540C\u4E00\u6B65\u9AA4\u81EA\u52A8\u6539\u7528 API Key\uFF0C\u5E76\u8BB0\u5165\u8FD0\u884C\u5386\u53F2\u3002"))));
1877
1877
  }
1878
1878
 
1879
- // .dsh-plugin/client/tool-install-state.mjs
1880
- function stableVersionOrder(left, right) {
1881
- const parse = (value) => /^([0-9]+)\.([0-9]+)\.([0-9]+)$/.exec(String(value ?? ""));
1882
- const current = parse(left);
1883
- const target = parse(right);
1884
- if (!current || !target) return null;
1885
- for (let index = 1; index <= 3; index += 1) {
1886
- const delta = Number(current[index]) - Number(target[index]);
1887
- if (delta) return Math.sign(delta);
1879
+ // .dsh-plugin/shared/version-order.mjs
1880
+ var VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,64})?$/;
1881
+ function compareReleaseVersions(left, right) {
1882
+ const parse = (value) => {
1883
+ const match = VERSION.exec(String(value ?? ""));
1884
+ return match ? { parts: match.slice(1, 4).map(Number), pre: String(value).includes("-") } : null;
1885
+ };
1886
+ const a = parse(left);
1887
+ const b2 = parse(right);
1888
+ if (!a || !b2) return null;
1889
+ for (let index = 0; index < 3; index += 1) {
1890
+ if (a.parts[index] !== b2.parts[index]) return a.parts[index] > b2.parts[index] ? 1 : -1;
1888
1891
  }
1892
+ if (a.pre !== b2.pre) return a.pre ? -1 : 1;
1889
1893
  return 0;
1890
1894
  }
1895
+
1896
+ // .dsh-plugin/client/tool-install-state.mjs
1891
1897
  function toolInstallAction({ tool, probe, readiness, job, probeStatus, latestVersion = null }) {
1892
1898
  const running = job?.status === "running";
1893
1899
  const installed = probe?.installed === true;
1894
- const order = installed && latestVersion ? stableVersionOrder(probe.version, latestVersion) : null;
1895
- const ready = readiness?.ready === true;
1900
+ const order = installed && latestVersion ? compareReleaseVersions(probe.version, latestVersion) : null;
1896
1901
  const desktop = tool.manager === "signed-windows-installer";
1897
1902
  const noRunner = desktop || tool.headlessAdapter === true;
1898
- const repair = installed && !noRunner && readiness?.ready === false;
1903
+ const repair = installed && !noRunner && readiness?.ready === false && order !== 0;
1899
1904
  const label = running ? "\u5B89\u88C5\u4E2D\u2026" : !installed ? job?.status === "failed" ? "\u91CD\u8BD5\u5B89\u88C5" : desktop ? "\u4E0B\u8F7D\u6700\u65B0\u5B89\u88C5\u5668" : "\u4E00\u952E\u5B89\u88C5\u6700\u65B0\u7248" : order === -1 ? `\u66F4\u65B0\u5230\u6700\u65B0\u7248 ${latestVersion}` : order === 1 ? "\u5DF2\u9AD8\u4E8E\u6700\u65B0\u6B63\u5F0F\u7248" : repair ? "\u4FEE\u590D\u5B98\u65B9\u6267\u884C\u5165\u53E3" : order === 0 ? "\u5DF2\u662F\u6700\u65B0\u7248\u672C" : "\u5B89\u88C5\u6700\u65B0\u7248";
1900
- const current = order === 0 && (ready || noRunner);
1905
+ const current = order === 0;
1906
+ const interactive = probeStatus === "ready" || probeStatus === "refreshing";
1901
1907
  return {
1902
1908
  label,
1903
- disabled: probeStatus !== "ready" || running || installed && (order === 1 || current)
1909
+ disabled: !interactive || running || installed && (order === 1 || current)
1904
1910
  };
1905
1911
  }
1912
+ function installClickRefusal({ tool, submitting = false, running = false } = {}) {
1913
+ if (!tool) return "\u672A\u77E5\u5B98\u65B9\u5DE5\u5177\uFF0C\u65E0\u6CD5\u5F00\u59CB\u5B89\u88C5\u3002";
1914
+ if (tool.unsupported) return String(tool.unsupportedReason ?? "").trim() || "\u6B64\u5DE5\u5177\u6682\u4E0D\u652F\u6301\u4E00\u952E\u5B89\u88C5\u3002";
1915
+ if (submitting || running) return "\u8BE5\u5DE5\u5177\u6B63\u5728\u5B89\u88C5\uFF0C\u8BF7\u7B49\u5F85\u5F53\u524D\u4EFB\u52A1\u7ED3\u675F\u3002";
1916
+ return "";
1917
+ }
1918
+ function payloadError(payload, fallback) {
1919
+ if (!payload || typeof payload !== "object") return fallback;
1920
+ if (typeof payload.error === "string" && payload.error.trim()) return payload.error.trim();
1921
+ const message = payload.error?.message;
1922
+ if (typeof message === "string" && message.trim()) return message.trim();
1923
+ return fallback;
1924
+ }
1925
+ function acceptedInstallJob(response) {
1926
+ const fallback = "\u5B89\u88C5\u4EFB\u52A1\u672A\u88AB\u63A5\u53D7\u3002";
1927
+ if (!response || typeof response !== "object") return { job: null, error: fallback };
1928
+ let payload = response;
1929
+ if (payload.ok === false) return { job: null, error: payloadError(payload, fallback) };
1930
+ if (payload.ok === true && payload.value && typeof payload.value === "object") {
1931
+ payload = payload.value;
1932
+ if (payload.ok === false) return { job: null, error: payloadError(payload, fallback) };
1933
+ if (payload.ok === true && payload.value && typeof payload.value === "object") payload = payload.value;
1934
+ }
1935
+ if (payload.accepted === true && payload.job && typeof payload.job === "object") return { job: payload.job, error: "" };
1936
+ return { job: null, error: payloadError(payload, fallback) };
1937
+ }
1938
+ function shouldApplyInstallStatus(localJob, remoteJob) {
1939
+ if (!remoteJob || typeof remoteJob !== "object") return false;
1940
+ const localStarted = typeof localJob?.startedAt === "string" ? localJob.startedAt : "";
1941
+ const remoteStarted = typeof remoteJob.startedAt === "string" ? remoteJob.startedAt : "";
1942
+ if (!localStarted || !remoteStarted) return true;
1943
+ return remoteStarted >= localStarted;
1944
+ }
1906
1945
 
1907
1946
  // .dsh-plugin/client/router-insights.jsx
1908
1947
  var import_react2 = __toESM(require("react"), 1);
@@ -12115,7 +12154,7 @@ var xterm_default = `/**
12115
12154
  `;
12116
12155
 
12117
12156
  // .dsh-plugin/client/host-version.mjs
12118
- var ROUTER_CLIENT_VERSION = true ? "0.16.1" : "";
12157
+ var ROUTER_CLIENT_VERSION = true ? "0.16.3" : "";
12119
12158
  var STALE_HOST_MESSAGE = "\u63D2\u4EF6\u540E\u53F0\u7248\u672C\u8F83\u65E7\uFF0C\u8BF7\u5B8C\u5168\u9000\u51FA\u5E76\u91CD\u542F Harness\uFF08\u5305\u62EC\u6258\u76D8\u56FE\u6807\uFF09\u540E\u518D\u4F7F\u7528\u3002";
12120
12159
  function isMissingRemoteMethod(message) {
12121
12160
  const value = String(message ?? "");
@@ -12921,7 +12960,10 @@ function PlanResults({ plan, ledger, headingRef }) {
12921
12960
  const purposeLabel = { analysis: "\u4EFB\u52A1\u5206\u6790", execution: "\u4EFB\u52A1\u5B9E\u65BD", verification: "\u72EC\u7ACB\u9A8C\u8BC1", synthesis: "\u7ED3\u679C\u6574\u5408" };
12922
12961
  return /* @__PURE__ */ import_react5.default.createElement("section", { className: "mr-card mr-results", "aria-label": "\u8DEF\u7531\u5EFA\u8BAE" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-card-head" }, /* @__PURE__ */ import_react5.default.createElement("div", null, /* @__PURE__ */ import_react5.default.createElement("h2", { className: "mr-card-title", ref: headingRef, tabIndex: -1 }, "\u8DEF\u7531\u5EFA\u8BAE"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-card-copy" }, "\u672C\u5730\u8BA1\u7B97\u5B8C\u6210\uFF0C\u672A\u5411\u6A21\u578B\u53D1\u9001\u4EFB\u52A1\u5185\u5BB9\u3002"))), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-card-body" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-result-grid" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-label" }, "\u63A8\u8350\u8DEF\u7EBF"), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-value" }, selected ? `${selected.provider}/${selected.model}` : "\u6682\u65E0\u8DEF\u7EBF")), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-label" }, "\u4EFB\u52A1\u590D\u6742\u5EA6 \xB7 \u5206\u503C\uFF080\u20131\uFF09"), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-value" }, { simple: "\u7B80\u5355", balanced: "\u4E2D\u7B49", complex: "\u590D\u6742" }[plan.complexity.band] || plan.complexity.band, " \xB7 ", plan.complexity.value)), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-label" }, "\u4F30\u7B97\u603B\u6210\u672C"), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-value" }, money2(plan.estimatedCost))), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-label" }, "\u8DEF\u7531\u65B9\u6848"), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-metric-value" }, ROUTING_PRESETS[plan.preset]?.label ?? "\u5747\u8861"))), budget?.exceeded && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-error", role: "alert" }, "\u6267\u884C\u524D\u9884\u7B97\u68C0\u67E5\uFF1A", budget.message, " \u5B9E\u9645\u6267\u884C\u65F6\u5C06\u6309\u8BBE\u7F6E\u81EA\u52A8\u964D\u7EA7\u6216\u6682\u505C\u8BE2\u95EE\u3002"), budget?.limited && !budget.exceeded && budget.estimateKnown && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, "\u6267\u884C\u524D\u9884\u7B97\u68C0\u67E5\uFF1A\u672C\u6B21\u9884\u4F30 ", money2(plan.estimatedCost), "\uFF0C\u5269\u4F59\u989D\u5EA6 ", money2(budget.remainingUsd), "\u3002"), plan.loginRequired && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, "\u63A8\u8350\u6A21\u578B\u7684\u5B98\u65B9 CLI \u672A\u767B\u5F55\uFF0C\u6267\u884C\u65F6\u76F4\u63A5\u8D70\u6A21\u578B\u76EE\u5F55 API\uFF1B\u53EF\u5728\u201C\u5B98\u65B9\u5DE5\u5177\u201D\u5361\u7247\u70B9\u201C\u53BB\u767B\u5F55\u201D\u3002"), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-channel-line" }, /* @__PURE__ */ import_react5.default.createElement("span", { className: "mr-control-label" }, "\u6267\u884C\u6E20\u9053"), /* @__PURE__ */ import_react5.default.createElement(ChannelBadge, { item: plan })), /* @__PURE__ */ import_react5.default.createElement("details", { className: "mr-plan-explanation" }, /* @__PURE__ */ import_react5.default.createElement("summary", null, "\u67E5\u770B\u89C4\u5212\u4F9D\u636E\u4E0E\u4F30\u7B97\u8BF4\u660E"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, plan.reason), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, plan.pricingNotice, " ", plan.qualityNotice, " ", plan.availabilityNotice, " ", plan.modalityNotice || "")), plan.optimization.budgetExceeded && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-error" }, "\u6309\u5DF2\u63D0\u4F9B\u5355\u4EF7\u4F30\u7B97\uFF0C\u4EFB\u52A1\u53EF\u80FD\u8D85\u8FC7\u672C\u6B21\u9884\u7B97\u3002\u9884\u7B97\u53EA\u5F71\u54CD\u5EFA\u8BAE\uFF0C\u4E0D\u4F1A\u963B\u6B62\u5B9E\u9645\u6263\u8D39\u3002"), plan.mode === "team" && /* @__PURE__ */ import_react5.default.createElement(import_react5.default.Fragment, null, /* @__PURE__ */ import_react5.default.createElement("h3", { className: "mr-section-title" }, "\u56E2\u961F\u5DE5\u4F5C\u5305"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, "\u4E0B\u65B9\u6A21\u578B\u662F\u89C4\u5212\u5EFA\u8BAE\uFF1B\u6258\u7BA1\u6267\u884C\u4F1A\u6309\u5382\u5546 CLI \u7684\u6A21\u578B\u540D\u89C4\u5219\u9009\u7528\uFF0C\u672A\u6838\u9A8C\u6620\u5C04\u65F6\u4F7F\u7528\u8BE5 CLI \u7684\u9ED8\u8BA4\u6A21\u578B\u3002"), plan.team.workPackages.length > 1 && /* @__PURE__ */ import_react5.default.createElement(DagView, { packages: plan.team.workPackages, label: "\u5DE5\u4F5C\u5305\u4F9D\u8D56\u56FE", renderNode: (item) => /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-route" }, item.recommendedProvider, "/", item.recommendedModel) }), plan.team.workPackages.length === 0 ? /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-empty" }, "\u5F53\u524D\u76EE\u5F55\u6CA1\u6709\u53EF\u5206\u914D\u7684\u6A21\u578B\u8DEF\u7EBF\u3002") : plan.team.workPackages.map((item, index) => /* @__PURE__ */ import_react5.default.createElement("details", { className: "mr-package mr-package-details", key: item.id }, /* @__PURE__ */ import_react5.default.createElement("summary", null, /* @__PURE__ */ import_react5.default.createElement("span", { className: "mr-package-name" }, index + 1, ". ", item.name), /* @__PURE__ */ import_react5.default.createElement("span", { className: "mr-package-route" }, item.recommendedProvider, "/", item.recommendedModel)), item.objective && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-copy" }, "\u5177\u4F53\u76EE\u6807\uFF1A", item.objective), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-copy" }, purposeLabel[item.purpose] ?? item.purpose, item.dependsOn.length > 0 ? ` \xB7 \u4F9D\u8D56\uFF1A${item.dependsOn.map((id2) => packageNames.get(id2) ?? id2).join("\u3001")}` : ""), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-copy" }, "\u96BE\u5EA6\uFF1A", { simple: "\u7B80\u5355", balanced: "\u4E2D\u7B49", complex: "\u56F0\u96BE" }[item.difficulty] || item.difficulty || "\u5F85\u8BC4\u4F30", " \xB7 \u8D39\u7528\uFF1A", money2(item.estimatedCost)), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-copy" }, "\u9A8C\u6536\uFF1A", item.verificationChecklist.join("\uFF1B")), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-channel-line" }, /* @__PURE__ */ import_react5.default.createElement(ChannelBadge, { item }), item.loginRequired && /* @__PURE__ */ import_react5.default.createElement("span", { className: "mr-pill" }, "CLI \u672A\u767B\u5F55")), item.channelDetail && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-package-copy" }, "\u6E20\u9053\u8BF4\u660E\uFF1A", item.channelDetail)))), plan.routingBypassed && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, "\u5DF2\u6307\u5B9A\u5355\u4E00\u6A21\u578B\uFF0C\u672A\u4E0E\u5176\u4ED6\u8DEF\u7EBF\u6BD4\u8F83\u3002\u5728\u5B98\u65B9\u4F1A\u8BDD\u4E2D\u8C03\u7528 ", /* @__PURE__ */ import_react5.default.createElement("code", null, "model_router_execute"), " \u5E76\u4F20\u5165\u8BE5 provider \u4E0E model \u5373\u53EF\u76F4\u63A5\u6267\u884C\uFF1B\u82E5\u8BE5\u6A21\u578B\u5141\u8BB8\u5B98\u65B9\u5DE5\u5177\uFF0C\u4F1A\u4F18\u5148\u4F7F\u7528\u5BF9\u5E94 CLI\u3002"), plan.mode === "team" && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-caption" }, plan.team.handoff)));
12923
12962
  }
12924
- var remoteError = (response, fallback) => text5(response?.error?.message) || text5(response?.value?.error) || fallback;
12963
+ var remoteError = (response, fallback) => remoteErrorText(
12964
+ text5(response?.error?.message) || text5(response?.value?.error) || (typeof response?.error === "string" ? response.error : ""),
12965
+ fallback
12966
+ );
12925
12967
  function probeLabel(probe) {
12926
12968
  if (!probe) return "\u5C1A\u672A\u68C0\u6D4B";
12927
12969
  if (probe.installed) return `\u5DF2\u5B89\u88C5${probe.version ? ` \xB7 ${probe.version}` : ""}`;
@@ -12939,10 +12981,22 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
12939
12981
  const request = import_react5.default.useRef(0);
12940
12982
  const submitting = import_react5.default.useRef(/* @__PURE__ */ new Set());
12941
12983
  const polling = import_react5.default.useRef(/* @__PURE__ */ new Set());
12984
+ const jobsRef = import_react5.default.useRef({});
12985
+ const rememberJobs = (updater) => {
12986
+ setJobs((previous) => {
12987
+ const next = typeof updater === "function" ? updater(previous) : updater;
12988
+ jobsRef.current = next;
12989
+ return next;
12990
+ });
12991
+ };
12942
12992
  const refresh = async () => {
12943
12993
  onRefreshHealth?.();
12944
12994
  const current = ++request.current;
12945
- setProbeState((previous) => ({ ...previous, status: "loading", error: "" }));
12995
+ setProbeState((previous) => ({
12996
+ ...previous,
12997
+ status: previous.probes.length > 0 ? "refreshing" : "loading",
12998
+ error: ""
12999
+ }));
12946
13000
  try {
12947
13001
  if (typeof listOfficialTools !== "function") throw new Error("\u5B98\u65B9\u5DE5\u5177\u5B89\u88C5\u6865\u5C1A\u672A\u52A0\u8F7D\u3002");
12948
13002
  const response = await listOfficialTools();
@@ -12955,8 +13009,16 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
12955
13009
  onProbes({ probes, capabilities, readiness, hostVersion: typeof response.value?.hostVersion === "string" ? response.value.hostVersion : null });
12956
13010
  } catch (error) {
12957
13011
  if (!mounted.current || current !== request.current) return;
12958
- setProbeState({ status: "error", probes: [], capabilities: [], readiness: [], error: text5(error?.message) || "\u65E0\u6CD5\u68C0\u6D4B\u5B98\u65B9\u5DE5\u5177\u3002" });
12959
- onProbes({ probes: [], capabilities: [], readiness: [] });
13012
+ const message = remoteErrorText(text5(error?.message), "\u65E0\u6CD5\u68C0\u6D4B\u5B98\u65B9\u5DE5\u5177\u3002");
13013
+ let kept = false;
13014
+ setProbeState((previous) => {
13015
+ if (previous.probes.length > 0) {
13016
+ kept = true;
13017
+ return { ...previous, status: "ready", error: message };
13018
+ }
13019
+ return { status: "error", probes: [], capabilities: [], readiness: [], error: message };
13020
+ });
13021
+ if (!kept) onProbes({ probes: [], capabilities: [], readiness: [] });
12960
13022
  }
12961
13023
  };
12962
13024
  import_react5.default.useEffect(() => {
@@ -12971,7 +13033,16 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
12971
13033
  return null;
12972
13034
  }
12973
13035
  })).then((entries) => {
12974
- if (mounted.current) setJobs((previous) => ({ ...Object.fromEntries(entries.filter(Boolean)), ...previous }));
13036
+ if (!mounted.current) return;
13037
+ rememberJobs((previous) => {
13038
+ const next = { ...previous };
13039
+ for (const entry of entries) {
13040
+ if (!entry) continue;
13041
+ const [id2, job] = entry;
13042
+ if (shouldApplyInstallStatus(previous[id2], job)) next[id2] = job;
13043
+ }
13044
+ return next;
13045
+ });
12975
13046
  });
12976
13047
  }
12977
13048
  return () => {
@@ -12991,12 +13062,20 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
12991
13062
  if (!listening || !mounted.current) return;
12992
13063
  if (!response?.ok) throw new Error(remoteError(response, "\u65E0\u6CD5\u83B7\u53D6\u5B89\u88C5\u8FDB\u5EA6\u3002"));
12993
13064
  const job = response.value?.job;
12994
- if (!job) throw new Error("\u5B89\u88C5\u4EFB\u52A1\u72B6\u6001\u6682\u4E0D\u53EF\u7528\u3002");
12995
- setJobs((previous) => ({ ...previous, [id2]: job }));
12996
- setRowErrors((previous) => ({ ...previous, [id2]: "" }));
13065
+ const local = jobsRef.current[id2];
13066
+ if (!job) {
13067
+ if (local?.status === "running") return;
13068
+ throw new Error("\u5B89\u88C5\u4EFB\u52A1\u72B6\u6001\u6682\u4E0D\u53EF\u7528\u3002");
13069
+ }
13070
+ if (!shouldApplyInstallStatus(local, job)) return;
13071
+ rememberJobs((previous) => shouldApplyInstallStatus(previous[id2], job) ? { ...previous, [id2]: job } : previous);
13072
+ setRowErrors((previous) => ({
13073
+ ...previous,
13074
+ [id2]: job.status === "failed" ? text5(job.error) || "\u5B89\u88C5\u5931\u8D25\u3002" : ""
13075
+ }));
12997
13076
  if (job.status !== "running") void refresh();
12998
13077
  } catch (error) {
12999
- if (listening && mounted.current) setRowErrors((previous) => ({ ...previous, [id2]: text5(error?.message) || "\u5B89\u88C5\u72B6\u6001\u8BFB\u53D6\u5931\u8D25\uFF0C\u5C06\u7EE7\u7EED\u91CD\u8BD5\u3002" }));
13078
+ if (listening && mounted.current) setRowErrors((previous) => ({ ...previous, [id2]: remoteErrorText(text5(error?.message), "\u5B89\u88C5\u72B6\u6001\u8BFB\u53D6\u5931\u8D25\uFF0C\u5C06\u7EE7\u7EED\u91CD\u8BD5\u3002") }));
13000
13079
  } finally {
13001
13080
  polling.current.delete(id2);
13002
13081
  }
@@ -13011,21 +13090,43 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
13011
13090
  }, [jobs, officialToolInstallStatus]);
13012
13091
  const install = async (id2) => {
13013
13092
  const tool = OFFICIAL_TOOLS.find((item) => item.id === id2);
13014
- if (!tool || tool.unsupported || submitting.current.has(id2) || jobs[id2]?.status === "running") return;
13093
+ const refusal = installClickRefusal({
13094
+ tool,
13095
+ submitting: submitting.current.has(id2),
13096
+ running: jobsRef.current[id2]?.status === "running"
13097
+ });
13098
+ if (refusal) {
13099
+ setRowErrors((previous) => ({ ...previous, [id2]: refusal }));
13100
+ return;
13101
+ }
13015
13102
  submitting.current.add(id2);
13103
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
13016
13104
  setRowErrors((previous) => ({ ...previous, [id2]: "" }));
13017
- setJobs((previous) => ({ ...previous, [id2]: { tool: id2, status: "running", outputTail: [] } }));
13105
+ rememberJobs((previous) => ({
13106
+ ...previous,
13107
+ [id2]: { tool: id2, status: "running", outputTail: [], startedAt, error: null }
13108
+ }));
13018
13109
  try {
13019
13110
  if (typeof installOfficialTool !== "function") throw new Error("\u5B98\u65B9\u5DE5\u5177\u5B89\u88C5\u6865\u5C1A\u672A\u52A0\u8F7D\u3002");
13020
13111
  const response = await installOfficialTool(id2);
13021
13112
  if (!mounted.current) return;
13022
- if (!response?.ok || !response.value?.accepted || !response.value?.job) throw new Error(remoteError(response, "\u5B89\u88C5\u4EFB\u52A1\u672A\u88AB\u63A5\u53D7\u3002"));
13023
- setJobs((previous) => ({ ...previous, [id2]: response.value.job }));
13113
+ const accepted = acceptedInstallJob(response);
13114
+ if (!accepted.job) throw new Error(remoteErrorText(accepted.error, "\u5B89\u88C5\u4EFB\u52A1\u672A\u88AB\u63A5\u53D7\u3002"));
13115
+ rememberJobs((previous) => shouldApplyInstallStatus(previous[id2], accepted.job) ? { ...previous, [id2]: accepted.job } : previous);
13024
13116
  } catch (error) {
13025
- if (mounted.current) {
13026
- setJobs((previous) => ({ ...previous, [id2]: { tool: id2, status: "failed", error: text5(error?.message) || "\u5B89\u88C5\u542F\u52A8\u5931\u8D25\u3002" } }));
13027
- setRowErrors((previous) => ({ ...previous, [id2]: text5(error?.message) || "\u5B89\u88C5\u542F\u52A8\u5931\u8D25\u3002" }));
13028
- }
13117
+ if (!mounted.current) return;
13118
+ const message = remoteErrorText(text5(error?.message), "\u5B89\u88C5\u542F\u52A8\u5931\u8D25\u3002");
13119
+ rememberJobs((previous) => ({
13120
+ ...previous,
13121
+ [id2]: {
13122
+ ...previous[id2] ?? {},
13123
+ tool: id2,
13124
+ status: "failed",
13125
+ error: message,
13126
+ startedAt: previous[id2]?.startedAt || startedAt
13127
+ }
13128
+ }));
13129
+ setRowErrors((previous) => ({ ...previous, [id2]: message }));
13029
13130
  } finally {
13030
13131
  submitting.current.delete(id2);
13031
13132
  }
@@ -13037,7 +13138,7 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
13037
13138
  const response = await cancelOfficialToolInstall(id2);
13038
13139
  if (!mounted.current) return;
13039
13140
  if (!response?.ok || !response.value?.accepted || !response.value?.job) throw new Error(remoteError(response, "\u53D6\u6D88\u8BF7\u6C42\u672A\u88AB\u63A5\u53D7\u3002"));
13040
- setJobs((previous) => ({ ...previous, [id2]: response.value.job }));
13141
+ rememberJobs((previous) => ({ ...previous, [id2]: response.value.job }));
13041
13142
  if (response.value.job.status !== "running") void refresh();
13042
13143
  } catch (error) {
13043
13144
  if (mounted.current) setRowErrors((previous) => ({ ...previous, [id2]: text5(error?.message) || "\u65E0\u6CD5\u53D6\u6D88\u5B89\u88C5\u3002" }));
@@ -13048,7 +13149,7 @@ function OfficialToolsCard({ listOfficialTools, installOfficialTool, cancelOffic
13048
13149
  const readinessById = Object.fromEntries(probeState.readiness.map((item) => [item.id, item]));
13049
13150
  return /* @__PURE__ */ import_react5.default.createElement("section", { className: "mr-card", "aria-label": "\u5B98\u65B9\u5DE5\u5177" }, /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-card-head" }, /* @__PURE__ */ import_react5.default.createElement("div", null, /* @__PURE__ */ import_react5.default.createElement("h2", { className: "mr-card-title" }, "\u5B98\u65B9\u5DE5\u5177 \xB7 \u4F53\u68C0"), /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-card-copy" }, "\u68C0\u6D4B\u672C\u673A\u5B98\u65B9\u5DE5\u5177\u7684\u5B89\u88C5\u3001\u7248\u672C\u548C\u767B\u5F55\u72B6\u6001\uFF0C\u5E76\u4ECE\u56FA\u5B9A\u6CE8\u518C\u8868\u4E00\u952E\u5B89\u88C5\u6216\u66F4\u65B0\u5230\u5404\u5382\u5546\u6700\u65B0\u7248\uFF08\u65B0\u7248\u672C\u672A\u7ECF\u63D2\u4EF6\u6D4B\u8BD5\uFF09\u3002\u672A\u767B\u5F55\u7684\u5DE5\u5177\u70B9\u201C\u53BB\u767B\u5F55\u201D\u67E5\u770B\u767B\u5F55\u547D\u4EE4\u3002ZCode \u4F1A\u6253\u5F00\u5B98\u65B9\u5B89\u88C5\u7A97\u53E3\u4F9B\u4F60\u9009\u62E9\u76EE\u5F55\uFF1B\u5B8C\u6210\u540E\u91CD\u65B0\u4F53\u68C0\u3002")), /* @__PURE__ */ import_react5.default.createElement("button", { className: "mr-button mr-button-secondary", type: "button", disabled: probeState.status === "loading", onClick: () => {
13050
13151
  void refresh();
13051
- } }, "\u91CD\u65B0\u4F53\u68C0")), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-card-body" }, probeState.status === "loading" && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-empty", role: "status" }, "\u6B63\u5728\u68C0\u6D4B\u672C\u673A\u5B98\u65B9\u5DE5\u5177\u2026"), probeState.status === "error" && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-error", role: "alert" }, probeState.error), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-tools", role: "list", "aria-label": "\u5B98\u65B9\u5DE5\u5177\u6CE8\u518C\u8868" }, OFFICIAL_TOOLS.map((tool) => {
13152
+ } }, "\u91CD\u65B0\u4F53\u68C0")), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-card-body" }, probeState.status === "loading" && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-empty", role: "status" }, "\u6B63\u5728\u68C0\u6D4B\u672C\u673A\u5B98\u65B9\u5DE5\u5177\u2026"), probeState.error && /* @__PURE__ */ import_react5.default.createElement("p", { className: "mr-error", role: "alert" }, probeState.error), /* @__PURE__ */ import_react5.default.createElement("div", { className: "mr-tools", role: "list", "aria-label": "\u5B98\u65B9\u5DE5\u5177\u6CE8\u518C\u8868" }, OFFICIAL_TOOLS.map((tool) => {
13052
13153
  const command = installCommandLine(tool);
13053
13154
  const probe = byId[tool.id];
13054
13155
  const capability = capabilitiesById[tool.id];
@@ -81,9 +81,14 @@ export class OfficialToolsRemoteService extends TypertRemoteService {
81
81
  /** Re-probe the local fixed registry; the caller cannot supply a command. */
82
82
  async list() {
83
83
  const tools = await probeAllTools({ fresh: true })
84
- const executionReadiness = await Promise.all(tools.map(tool => tool.installed
85
- ? officialToolReadiness(tool.id)
86
- : Promise.resolve({ id: tool.id, ready: false, reason: 'CLI 尚未安装或版本检测失败。' })))
84
+ const executionReadiness = await Promise.all(tools.map(async tool => {
85
+ if (!tool.installed) return { id: tool.id, ready: false, reason: 'CLI 尚未安装或版本检测失败。' }
86
+ try {
87
+ return await officialToolReadiness(tool.id)
88
+ } catch (error) {
89
+ return { id: tool.id, ready: false, reason: `执行入口检测失败:${String(error?.message ?? error).slice(0, 200)}` }
90
+ }
91
+ }))
87
92
  return { tools, executionCapabilities: officialToolExecutionCapabilities(), executionReadiness, hostVersion: HOST_PLUGIN_VERSION }
88
93
  }
89
94
 
@@ -12,6 +12,9 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { resolveStateHome } from './router-state.mjs'
14
14
  import { OFFICIAL_TOOLS } from './official-tool-registry.mjs'
15
+ import { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
16
+
17
+ export { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
15
18
 
16
19
  export const LATEST_CACHE_MS = 12 * 60 * 60_000
17
20
  export const LATEST_FAILURE_RETRY_MS = 30 * 60_000
@@ -19,27 +22,6 @@ export const LATEST_TIMEOUT_MS = 5_000
19
22
  export const NPM_REGISTRY = 'https://registry.npmjs.org'
20
23
  export const ZCODE_DOWNLOAD_PAGE = 'https://zcode.z.ai/en/docs/install'
21
24
  const MAX_DOCUMENT_BYTES = 2_000_000
22
- const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,64})?$/
23
-
24
- export function isReleaseVersion(value) {
25
- return typeof value === 'string' && VERSION.test(value)
26
- }
27
-
28
- /** Numeric comparison of x.y.z[-pre]; a prerelease sorts below its release. */
29
- export function compareReleaseVersions(left, right) {
30
- const parse = value => {
31
- const match = VERSION.exec(String(value ?? ''))
32
- return match ? { parts: match.slice(1, 4).map(Number), pre: String(value).includes('-') } : null
33
- }
34
- const a = parse(left)
35
- const b = parse(right)
36
- if (!a || !b) return null
37
- for (let index = 0; index < 3; index += 1) {
38
- if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1
39
- }
40
- if (a.pre !== b.pre) return a.pre ? -1 : 1
41
- return 0
42
- }
43
25
 
44
26
  /** True only when both versions parse and the latest is strictly newer. */
45
27
  export function updateAvailable(installed, latest) {
@@ -171,6 +171,42 @@ function versionAtLeast(value, minimum) {
171
171
  return true
172
172
  }
173
173
 
174
+ /** Relative bin path inside a package. Reject `..` and empty segments. */
175
+ export function normalizeRelativeBin(value) {
176
+ const text = String(value ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '')
177
+ if (!text || text.split('/').some(part => part === '' || part === '..')) return ''
178
+ return text
179
+ }
180
+
181
+ /**
182
+ * Official entry files to try, declared bin first. `./dist/main.mjs` matches
183
+ * the pinned `dist/main.mjs`. A package whose bin moved still resolves when
184
+ * the file stays inside that package.
185
+ */
186
+ export function packageBinCandidates(binField, binName, expectedBin) {
187
+ const declared = normalizeRelativeBin(binField && typeof binField === 'object' ? binField[binName] : binField)
188
+ const expected = normalizeRelativeBin(expectedBin)
189
+ return [...new Set([declared, expected].filter(Boolean))]
190
+ }
191
+
192
+ async function readPackageEntry(packageDirectory, packageName, binName, expectedBin, workspace, seen) {
193
+ try {
194
+ const manifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
195
+ if (manifest.name !== packageName) return null
196
+ const packageRoot = await realpath(packageDirectory)
197
+ if (packageRoot === workspace || inside(workspace, packageRoot) || seen.has(packageRoot)) return null
198
+ for (const relativeBin of packageBinCandidates(manifest.bin, binName, expectedBin)) {
199
+ try {
200
+ const entry = await realpath(join(packageRoot, relativeBin))
201
+ if (!inside(packageRoot, entry) || !(await stat(entry)).isFile()) continue
202
+ seen.add(packageRoot)
203
+ return { entry, packageRoot, version: manifest.version, source: 'verified-npm-package' }
204
+ } catch { /* try the package's other bin candidate */ }
205
+ }
206
+ } catch { /* this directory is not the requested package */ }
207
+ return null
208
+ }
209
+
174
210
  /** Resolve the package's real `bin` file, never its Windows .cmd shim. */
175
211
  async function findGlobalPackageEntries(packageName, binName, expectedBin, workspace) {
176
212
  await ensureNpmPrefixOnPath()
@@ -181,19 +217,13 @@ async function findGlobalPackageEntries(packageName, binName, expectedBin, works
181
217
  const moduleParents = [join(directory, 'node_modules')]
182
218
  if (basename(directory).toLowerCase() === '.bin') moduleParents.push(resolve(directory, '..'))
183
219
  if (!IS_WINDOWS) moduleParents.push(resolve(directory, '..', 'lib', 'node_modules'))
184
- for (const modules of moduleParents) {
185
- const packageDirectory = join(modules, ...packageParts)
186
- try {
187
- const manifest = JSON.parse(await readFile(join(packageDirectory, 'package.json'), 'utf8'))
188
- if (manifest.name !== packageName || manifest.bin?.[binName] !== expectedBin) continue
189
- const packageRoot = await realpath(packageDirectory)
190
- if (packageRoot === workspace || inside(workspace, packageRoot)) continue
191
- if (seen.has(packageRoot)) continue
192
- const entry = await realpath(join(packageRoot, expectedBin))
193
- if (!inside(packageRoot, entry) || !(await stat(entry)).isFile()) continue
194
- seen.add(packageRoot)
195
- found.push({ entry, packageRoot, version: manifest.version, source: 'verified-npm-package' })
196
- } catch { /* this PATH entry is not the requested package */ }
220
+ const candidates = moduleParents.map(modules => join(modules, ...packageParts))
221
+ // Native installs put the exe in `<package>/bin` and that directory on PATH
222
+ // (for example D:\Program Files\Claude\node_modules\@anthropic-ai\claude-code\bin).
223
+ candidates.push(directory, resolve(directory, '..'))
224
+ for (const packageDirectory of candidates) {
225
+ const entry = await readPackageEntry(packageDirectory, packageName, binName, expectedBin, workspace, seen)
226
+ if (entry) found.push(entry)
197
227
  }
198
228
  }
199
229
  return found
@@ -7,6 +7,8 @@
7
7
  * a package name, executable or argument. Installs are serialized (npm global
8
8
  * state must not interleave), output is kept as a bounded tail, and success is
9
9
  * always confirmed by a fresh probe rather than the installer's exit code.
10
+ * When a newer release is known, a probe that is still older fails the job
11
+ * instead of reporting success.
10
12
  */
11
13
  import { spawn } from 'node:child_process'
12
14
  import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
@@ -15,6 +17,7 @@ import { getOfficialTool, installCommandLine, OFFICIAL_TOOLS } from './official-
15
17
  import { discoverZCodeBundle, unverifiedZCodeInstalls } from './zcode-bundle.mjs'
16
18
  import { openZCodeInstaller } from './zcode-installer.mjs'
17
19
  import { compareReleaseVersions, latestVersions } from './latest-versions.mjs'
20
+ import { versionStillOlder } from './version-order.mjs'
18
21
 
19
22
  const PROBE_TIMEOUT_MS = 8_000
20
23
  const INSTALL_TIMEOUT_MS = 900_000
@@ -30,6 +33,70 @@ const probeCache = new Map()
30
33
  const PROBE_CACHE_MS = 60_000
31
34
  const installJobs = new Map()
32
35
  let installChain = Promise.resolve()
36
+ const CLAUDE_UPDATE_TIMEOUT_MS = 180_000
37
+
38
+ /** Non-interactive updater for a native Claude Code install. npm does not move that copy. */
39
+ export const CLAUDE_UPDATE_ARGS = Object.freeze(['update'])
40
+
41
+ /**
42
+ * Spawn shape for a fixed registry command.
43
+ *
44
+ * Windows needs `shell: true` so `npm.cmd` / `where.exe` resolve. Node itself
45
+ * wraps that as `cmd.exe /d /s /c`. Pre-quoting every argument (0.16.2) made
46
+ * cmd strip the wrong quotes and launch `node` against a missing script:
47
+ * `MODULE_NOT_FOUND` at `executeUserEntryPoint` with an empty requireStack.
48
+ * That failed `npm config get prefix`, `--version`, and `npm install -g` for
49
+ * every official tool. Keep the executable and argv separate.
50
+ */
51
+ export function shellSpawnSpec(executable, args = [], { useShell = false } = {}) {
52
+ return { file: String(executable), args: [...(args ?? [])], shell: Boolean(useShell) }
53
+ }
54
+
55
+ /** Decode a CLI banner. Native Windows exes often write UTF-16 to a pipe. */
56
+ export function decodeProcessOutput(value) {
57
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value ?? '')
58
+ if (bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xFE) {
59
+ return bytes.toString('utf16le').replace(/^\uFEFF/, '')
60
+ }
61
+ if (bytes.length >= 2 && bytes[0] === 0xFE && bytes[1] === 0xFF) {
62
+ const swapped = Buffer.from(bytes)
63
+ swapped.swap16()
64
+ return swapped.toString('utf16le').replace(/^\uFEFF/, '')
65
+ }
66
+ const sample = bytes.subarray(0, Math.min(bytes.length, 64))
67
+ let zeros = 0
68
+ for (let index = 1; index < sample.length; index += 2) if (sample[index] === 0) zeros += 1
69
+ const pairs = Math.floor(sample.length / 2)
70
+ if (pairs >= 4 && zeros >= pairs * 0.6) return bytes.toString('utf16le').replace(/\u0000/g, '')
71
+ return bytes.toString('utf8').replace(/^\uFEFF/, '')
72
+ }
73
+
74
+ /** Absolute paths printed by `where` / `which`, one per line. */
75
+ export function locatorPaths(stdout) {
76
+ return String(stdout ?? '').split(/\r?\n/).map(line => line.trim().replace(/^"|"$/g, '')).filter(line => {
77
+ if (!line || /^INFO:/i.test(line)) return false
78
+ return isAbsolute(line)
79
+ })
80
+ }
81
+
82
+ /** Visible failure when the installer exited cleanly but the probed copy is still older. */
83
+ export function staleInstallMessage(label, installedVersion, latestVersion) {
84
+ return `${label} 的更新没有生效:检测到的版本仍是 ${installedVersion},低于最新 ${latestVersion}。PATH 上可能有另一份更早的安装。请查看本行安装日志。`
85
+ }
86
+
87
+ /**
88
+ * Which updater to run. `order` is compareReleaseVersions(installed, latest):
89
+ * -1 older, 0 same, 1 newer, null when either version is unknown.
90
+ */
91
+ export function officialUpdatePlan({ toolId, installed, order, platform }) {
92
+ if (installed && order === 1) return 'refuse-downgrade'
93
+ if (installed && order === 0) return 'already-current'
94
+ if (installed && order === -1 && toolId === 'claude-code') return 'claude-update-then-registry'
95
+ if (installed && (order === -1 || order === null) && toolId === 'minimax-code' && platform === 'win32') {
96
+ return 'minimax-official-installer'
97
+ }
98
+ return 'registry-install'
99
+ }
33
100
 
34
101
  async function trustedExecutionReadiness(toolId) {
35
102
  // Loaded only during an install to avoid coupling fast version probes to
@@ -44,19 +111,28 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
44
111
  resolve({ ok: false, code: null, stdout: '', stderr: '', timedOut: false, cancelled: true })
45
112
  return
46
113
  }
47
- let stdout = ''
48
- let stderr = ''
114
+ const stdoutChunks = []
115
+ const stderrChunks = []
116
+ const decoded = chunks => decodeProcessOutput(Buffer.concat(chunks))
117
+ const pushChunk = (chunks, data) => {
118
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data)
119
+ chunks.push(buf)
120
+ let total = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
121
+ while (total > 64_000 && chunks.length > 1) total -= chunks.shift().length
122
+ return decodeProcessOutput(buf)
123
+ }
49
124
  let settled = false
50
125
  let stopReason = null
51
126
  let child
52
127
  try {
53
- child = spawn(executable, args, {
128
+ const spec = shellSpawnSpec(executable, args, { useShell })
129
+ child = spawn(spec.file, spec.args, {
54
130
  stdio: ['ignore', 'pipe', 'pipe'],
55
131
  windowsHide: true,
56
132
  // Windows npm CLIs install as .cmd shims; only cmd.exe can execute
57
133
  // them. Safe here because every executable and argument comes from
58
134
  // the fixed registry — caller input never reaches this boundary.
59
- shell: useShell,
135
+ shell: spec.shell,
60
136
  ...(env ? { env } : {}),
61
137
  })
62
138
  } catch (error) {
@@ -87,7 +163,7 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
87
163
  } else {
88
164
  try { child.kill('SIGTERM') } catch { /* already gone */ }
89
165
  }
90
- graceTimer = setTimeout(() => finish({ ok: false, code: null, stdout, stderr,
166
+ graceTimer = setTimeout(() => finish({ ok: false, code: null, stdout: decoded(stdoutChunks), stderr: decoded(stderrChunks),
91
167
  timedOut: reason === 'timeout', cancelled: reason === 'cancel' }), 5_000)
92
168
  graceTimer.unref?.()
93
169
  }
@@ -97,20 +173,20 @@ function runCapture(executable, args, { timeoutMs, onOutput, useShell = false, s
97
173
  if (signal?.aborted) onAbort()
98
174
  child.stdout?.on('data', data => {
99
175
  if (settled || stopReason) return
100
- stdout = appendBounded(stdout, String(data))
101
- onOutput?.(String(data))
176
+ onOutput?.(pushChunk(stdoutChunks, data))
102
177
  })
103
178
  child.stderr?.on('data', data => {
104
179
  if (settled || stopReason) return
105
- stderr = appendBounded(stderr, String(data))
106
- onOutput?.(String(data))
180
+ onOutput?.(pushChunk(stderrChunks, data))
107
181
  })
108
182
  child.on('error', error => {
183
+ const stdout = decoded(stdoutChunks)
184
+ const stderr = decoded(stderrChunks)
109
185
  finish({ ok: false, code: null, stdout, stderr: `${stderr}${error.message}`.trim(),
110
186
  timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel' })
111
187
  })
112
188
  child.on('close', (code, signal) => {
113
- finish({ ok: !stopReason && code === 0, code, stdout, stderr,
189
+ finish({ ok: !stopReason && code === 0, code, stdout: decoded(stdoutChunks), stderr: decoded(stderrChunks),
114
190
  timedOut: stopReason === 'timeout', cancelled: stopReason === 'cancel', signal })
115
191
  })
116
192
  })
@@ -145,14 +221,14 @@ async function fetchOfficialMiniMaxInstaller(signal) {
145
221
  return officialMiniMaxInstaller(Buffer.concat(chunks))
146
222
  }
147
223
 
148
- async function runOfficialMiniMaxInstaller(job) {
224
+ async function runOfficialMiniMaxInstaller(job, { reason } = {}) {
149
225
  let staging = null
150
226
  let prefixRoot = null
151
227
  try {
152
228
  job.finishedAt = null
153
229
  job.error = null
154
230
  job.phase = 'downloading-and-verifying'
155
- pushLine(job, '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
231
+ pushLine(job, reason || '— npm 原生依赖未就绪;改用 MiniMax 官方 Windows 安装脚本(安装最新版)—')
156
232
  const prefixResult = await runCapture('npm', ['config', 'get', 'prefix'], {
157
233
  timeoutMs: 5_000, useShell: true, signal: job.controller.signal,
158
234
  })
@@ -208,6 +284,10 @@ async function runOfficialMiniMaxInstaller(job) {
208
284
  if (!probe.installed || !readiness.ready) {
209
285
  throw new Error(`官方安装器完成,但执行入口未通过 npm registry 摘要核验:${readiness.reason ?? probe.detail}`)
210
286
  }
287
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
288
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
289
+ throw new Error(staleInstallMessage(job.tool.label, probe.version, latest.version))
290
+ }
211
291
  job.status = 'succeeded'
212
292
  job.finishedAt = new Date().toISOString()
213
293
  pushLine(job, `— 官方安装器已完成,MiniMax Code ${probe.version ?? ''} 的执行入口已核验 —`)
@@ -229,11 +309,6 @@ async function runOfficialMiniMaxInstaller(job) {
229
309
  /** Tools whose ready-check failure can be repaired by reinstalling the same version. */
230
310
  const CAPABILITY_REPAIRABLE = new Set(['claude-code', 'codex', 'kimi-code', 'minimax-code', 'mimo-code', 'grok-build'])
231
311
 
232
- function appendBounded(current, addition) {
233
- const merged = current + addition
234
- return merged.length > 64_000 ? merged.slice(merged.length - 32_000) : merged
235
- }
236
-
237
312
  /** Extract the first version-looking token from a CLI banner. */
238
313
  export function versionFromBanner(banner) {
239
314
  const match = String(banner ?? '').match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/)
@@ -288,16 +363,28 @@ async function probeUncached(tool, runner) {
288
363
  if (!located.ok || !located.stdout.trim()) {
289
364
  continue
290
365
  }
291
- const attempt = await runner(executable, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS })
366
+ const attempt = await runner(executable, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: IS_WINDOWS })
292
367
  if (attempt.timedOut) {
293
368
  return { id: tool.id, installed: false, version: null, status: 'probe-timeout', detail: `${executable} --version 超时(${PROBE_TIMEOUT_MS / 1000}s)` }
294
369
  }
295
- if (attempt.ok) {
296
- const banner = `${attempt.stdout}\n${attempt.stderr}`.trim()
370
+ const attempts = [attempt]
371
+ if (!versionFromBanner(probeBanner(attempt))) {
372
+ for (const target of locatorPaths(located.stdout)) {
373
+ if (target === executable) continue
374
+ const direct = await runner(target, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, useShell: false })
375
+ attempts.push(direct)
376
+ if (direct.timedOut) break
377
+ if (direct.ok || versionFromBanner(probeBanner(direct))) break
378
+ }
379
+ }
380
+ const chosen = attempts.find(item => item && (item.ok || versionFromBanner(probeBanner(item)))) ?? attempt
381
+ const banner = probeBanner(chosen)
382
+ const version = versionFromBanner(banner)
383
+ if (chosen.ok || version) {
297
384
  return {
298
385
  id: tool.id,
299
386
  installed: true,
300
- version: versionFromBanner(banner),
387
+ version,
301
388
  status: 'installed',
302
389
  detail: tool.probeNote ?? '',
303
390
  bannerLine: banner.split('\n').find(Boolean)?.slice(0, 200) ?? '',
@@ -308,7 +395,7 @@ async function probeUncached(tool, runner) {
308
395
  installed: false,
309
396
  version: null,
310
397
  status: 'probe-failed',
311
- detail: `${executable} --version 退出码 ${attempt.code ?? '信号终止'};程序可能损坏或版本过旧。${attempt.stderr.split('\n').find(Boolean)?.slice(0, 120) ?? ''}`,
398
+ detail: `${executable} --version 退出码 ${chosen.code ?? '信号终止'};程序可能损坏或版本过旧。${String(chosen.stderr ?? '').split('\n').find(Boolean)?.slice(0, 120) ?? ''}`,
312
399
  }
313
400
  }
314
401
  return { id: tool.id, installed: false, version: null, status: 'not-installed', detail: '未在 PATH 中找到官方命令。' }
@@ -316,7 +403,7 @@ async function probeUncached(tool, runner) {
316
403
 
317
404
  /** Default runner: real spawn with the platform's shell for .cmd shims. */
318
405
  export const defaultRunner = (executable, args, options = {}) =>
319
- runCapture(executable, args, { ...options, useShell: IS_WINDOWS })
406
+ runCapture(executable, args, { useShell: IS_WINDOWS, ...options })
320
407
 
321
408
  let prefixPromise = null
322
409
 
@@ -365,12 +452,31 @@ export async function installedToolIds() {
365
452
  * Probe every registry tool in parallel (the slowest single probe bounds the
366
453
  * wall time; the cache keeps repeat calls instant).
367
454
  */
455
+ /** One tool's probe failure must not reject detection of the rest of the page. */
456
+ export async function probeToolSafely(tool, runner, options) {
457
+ try {
458
+ return await probeToolWith(tool, runner, options)
459
+ } catch (error) {
460
+ return {
461
+ id: tool.id,
462
+ installed: false,
463
+ version: null,
464
+ status: 'probe-failed',
465
+ detail: `检测失败:${String(error?.message ?? error).slice(0, 200)}`,
466
+ }
467
+ }
468
+ }
469
+
368
470
  export async function probeAllTools({ fresh = false } = {}) {
369
471
  await ensureNpmPrefixOnPath()
370
- return Promise.all(OFFICIAL_TOOLS.map(tool => probeToolWith(tool, defaultRunner,
472
+ return Promise.all(OFFICIAL_TOOLS.map(tool => probeToolSafely(tool, defaultRunner,
371
473
  fresh ? { cacheMs: 0 } : {})))
372
474
  }
373
475
 
476
+ function probeBanner(attempt) {
477
+ return `${attempt?.stdout ?? ''}\n${attempt?.stderr ?? ''}`.trim()
478
+ }
479
+
374
480
  export function probeSnapshot() {
375
481
  return [...probeCache.values()].map(entry => entry.value)
376
482
  }
@@ -444,6 +550,86 @@ export function startInstall(toolId) {
444
550
  return jobView(job)
445
551
  }
446
552
 
553
+ function captureLines(job) {
554
+ return chunk => {
555
+ for (const line of String(chunk).split(/\r?\n/)) {
556
+ if (line.trim()) pushLine(job, line.slice(0, 500))
557
+ }
558
+ }
559
+ }
560
+
561
+ /** Mark the job succeeded only when the fresh probe is installed, ready, and not still older. */
562
+ async function finishVerifiedInstall(job, probe) {
563
+ job.postInstallProbe = probe
564
+ if (job.cancelRequested) { markCancelled(job); return true }
565
+ if (!probe.installed) {
566
+ job.status = 'failed'
567
+ job.finishedAt = new Date().toISOString()
568
+ job.error = `安装命令已完成,但重探测失败:${probe.detail}`
569
+ return true
570
+ }
571
+ const latest = await latestVersions.lookup(job.tool).catch(() => null)
572
+ if (latest?.version && versionStillOlder(probe.version, latest.version)) {
573
+ failBecauseStillOlder(job, probe, latest.version)
574
+ return true
575
+ }
576
+ const readiness = await trustedExecutionReadiness(job.tool.id)
577
+ if (job.cancelRequested) { markCancelled(job); return true }
578
+ if (!readiness.ready) {
579
+ if (IS_WINDOWS && job.tool.id === 'minimax-code') {
580
+ await runOfficialMiniMaxInstaller(job)
581
+ return true
582
+ }
583
+ job.status = 'failed'
584
+ job.finishedAt = new Date().toISOString()
585
+ job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
586
+ return true
587
+ }
588
+ job.status = 'succeeded'
589
+ job.finishedAt = new Date().toISOString()
590
+ pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
591
+ return true
592
+ }
593
+
594
+ function failBecauseStillOlder(job, probe, latestVersion) {
595
+ job.postInstallProbe = probe
596
+ job.status = 'failed'
597
+ job.finishedAt = new Date().toISOString()
598
+ job.error = staleInstallMessage(job.tool.label, probe?.version, latestVersion)
599
+ }
600
+
601
+ /**
602
+ * Native Claude Code lives outside the npm prefix. `claude update` is the
603
+ * vendor's non-interactive updater; return true when the job is finished.
604
+ */
605
+ async function tryClaudeUpdate(job, latestVersion) {
606
+ if (job.cancelRequested) { markCancelled(job); return true }
607
+ pushLine(job, '$ claude update')
608
+ job.phase = 'installing'
609
+ const outcome = await runCapture('claude', CLAUDE_UPDATE_ARGS, {
610
+ timeoutMs: CLAUDE_UPDATE_TIMEOUT_MS,
611
+ useShell: IS_WINDOWS,
612
+ signal: job.controller.signal,
613
+ onOutput: captureLines(job),
614
+ })
615
+ if (job.status !== 'running') return true
616
+ if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return true }
617
+ if (!outcome.ok) {
618
+ pushLine(job, `— claude update 未完成(退出码 ${outcome.code ?? '信号终止'}),将继续尝试注册表安装 —`)
619
+ return false
620
+ }
621
+ await ensureNpmPrefixOnPath()
622
+ probeCache.delete(job.tool.id)
623
+ const probe = await probeToolWith(job.tool, defaultRunner)
624
+ if (job.cancelRequested) { markCancelled(job); return true }
625
+ if (latestVersion && versionStillOlder(probe.version, latestVersion)) {
626
+ job.postInstallProbe = probe
627
+ pushLine(job, `— claude update 已结束,但探测到的版本仍是 ${probe.version},低于 ${latestVersion} —`)
628
+ return false
629
+ }
630
+ return finishVerifiedInstall(job, probe)
631
+ }
632
+
447
633
  async function runInstallJob(manager, args, job) {
448
634
  if (job.status !== 'running') return
449
635
  job.phase = 'preflight'
@@ -452,10 +638,12 @@ async function runInstallJob(manager, args, job) {
452
638
  probeCache.delete(job.tool.id)
453
639
  const before = await probeToolWith(job.tool, defaultRunner)
454
640
  if (job.cancelRequested) { markCancelled(job); return }
641
+ let latest = null
642
+ let order = null
455
643
  if (before.installed) {
456
- const latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
644
+ latest = await latestVersions.lookup(job.tool, { fresh: true }).catch(() => null)
457
645
  if (job.cancelRequested) { markCancelled(job); return }
458
- const order = latest?.version ? compareReleaseVersions(before.version, latest.version) : null
646
+ order = latest?.version ? compareReleaseVersions(before.version, latest.version) : null
459
647
  if (order === 1) {
460
648
  job.status = 'failed'
461
649
  job.finishedAt = new Date().toISOString()
@@ -476,59 +664,76 @@ async function runInstallJob(manager, args, job) {
476
664
  pushLine(job, `— 已是最新版本 ${before.version},但执行入口未就绪:${readiness.reason};重新安装修复 —`)
477
665
  }
478
666
  }
667
+ const plan = officialUpdatePlan({
668
+ toolId: job.tool.id,
669
+ installed: before.installed === true,
670
+ order,
671
+ platform: process.platform,
672
+ })
673
+ let triedClaudeUpdate = false
674
+ if (plan === 'claude-update-then-registry') {
675
+ triedClaudeUpdate = true
676
+ const finished = await tryClaudeUpdate(job, latest?.version)
677
+ if (finished || job.status !== 'running') return
678
+ } else if (plan === 'minimax-official-installer') {
679
+ await runOfficialMiniMaxInstaller(job, {
680
+ reason: '— 已安装的 MiniMax Code 由官方安装目录接管;直接运行官方 Windows 安装脚本以更新到最新版 —',
681
+ })
682
+ return
683
+ }
479
684
  job.phase = 'installing'
480
685
  const outcome = await runCapture(manager, args, {
481
686
  timeoutMs: INSTALL_TIMEOUT_MS,
482
687
  useShell: IS_WINDOWS,
483
688
  signal: job.controller.signal,
484
- onOutput: chunk => {
485
- for (const line of chunk.split(/\r?\n/)) {
486
- if (line.trim()) pushLine(job, line.slice(0, 500))
487
- }
488
- },
689
+ onOutput: captureLines(job),
489
690
  })
490
691
  if (job.status !== 'running') return
491
692
  if (outcome.cancelled || job.cancelRequested) { markCancelled(job); return }
492
- job.finishedAt = new Date().toISOString()
493
693
  job.exitCode = outcome.code
494
694
  job.timedOut = outcome.timedOut
495
695
  if (outcome.timedOut) {
496
696
  job.status = 'failed'
697
+ job.finishedAt = new Date().toISOString()
497
698
  job.error = `安装超时(${INSTALL_TIMEOUT_MS / 1000}s),已终止。`
498
- } else if (outcome.ok) {
499
- job.phase = 'verifying'
500
- await ensureNpmPrefixOnPath()
501
- probeCache.delete(job.tool.id)
502
- const probe = await probeToolWith(job.tool, defaultRunner)
503
- if (job.cancelRequested) { markCancelled(job); return }
504
- job.postInstallProbe = probe
505
- if (!probe.installed) {
506
- job.status = 'failed'
507
- job.error = `安装命令已完成,但重探测失败:${probe.detail}`
508
- } else {
509
- const latest = await latestVersions.lookup(job.tool).catch(() => null)
510
- if (latest?.version && compareReleaseVersions(probe.version, latest.version) === -1) {
511
- pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latest.version};可能有另一份旧安装排在 PATH 前面 —`)
512
- }
513
- const readiness = await trustedExecutionReadiness(job.tool.id)
514
- if (job.cancelRequested) { markCancelled(job); return }
515
- if (!readiness.ready) {
516
- if (IS_WINDOWS && job.tool.id === 'minimax-code') await runOfficialMiniMaxInstaller(job)
517
- else {
518
- job.status = 'failed'
519
- job.error = `安装命令和版本探测已成功,但官方执行入口未就绪:${readiness.reason}`
520
- }
521
- } else {
522
- job.status = 'succeeded'
523
- pushLine(job, `— 已安装 ${job.tool.label} ${probe.version ?? ''},官方执行入口已核验(新版本未经插件测试)—`)
524
- }
699
+ return
700
+ }
701
+ if (!outcome.ok) {
702
+ if (IS_WINDOWS && job.tool.id === 'minimax-code') {
703
+ await runOfficialMiniMaxInstaller(job)
704
+ return
525
705
  }
526
- } else if (IS_WINDOWS && job.tool.id === 'minimax-code') {
527
- await runOfficialMiniMaxInstaller(job)
528
- } else {
529
706
  job.status = 'failed'
707
+ job.finishedAt = new Date().toISOString()
530
708
  job.error = `安装命令失败(退出码 ${outcome.code ?? '信号终止'})。${outcome.stderr.split('\n').find(Boolean)?.slice(0, 200) ?? ''}`
709
+ return
710
+ }
711
+ job.phase = 'verifying'
712
+ await ensureNpmPrefixOnPath()
713
+ probeCache.delete(job.tool.id)
714
+ const probe = await probeToolWith(job.tool, defaultRunner)
715
+ if (job.cancelRequested) { markCancelled(job); return }
716
+ job.postInstallProbe = probe
717
+ const latestAfter = (await latestVersions.lookup(job.tool).catch(() => null)) ?? latest
718
+ const stillOlder = Boolean(latestAfter?.version && versionStillOlder(probe.version, latestAfter.version))
719
+ if (stillOlder && job.tool.id === 'claude-code' && !triedClaudeUpdate) {
720
+ triedClaudeUpdate = true
721
+ const finished = await tryClaudeUpdate(job, latestAfter.version)
722
+ if (finished || job.status !== 'running') return
723
+ failBecauseStillOlder(job, job.postInstallProbe ?? probe, latestAfter.version)
724
+ return
725
+ }
726
+ if (stillOlder && IS_WINDOWS && job.tool.id === 'minimax-code') {
727
+ pushLine(job, `— npm 完成后 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version};改用官方安装脚本 —`)
728
+ await runOfficialMiniMaxInstaller(job)
729
+ return
730
+ }
731
+ if (stillOlder) {
732
+ pushLine(job, `— 注意:PATH 上的 ${job.tool.label} 仍是 ${probe.version},低于最新 ${latestAfter.version} —`)
733
+ failBecauseStillOlder(job, probe, latestAfter.version)
734
+ return
531
735
  }
736
+ await finishVerifiedInstall(job, probe)
532
737
  }
533
738
 
534
739
  async function runZCodeInstallJob(job) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure x.y.z[-pre] ordering shared by the Host and the Desktop client.
3
+ * No Node APIs: the client bundle imports this file.
4
+ */
5
+ const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,64})?$/
6
+
7
+ export function isReleaseVersion(value) {
8
+ return typeof value === 'string' && VERSION.test(value)
9
+ }
10
+
11
+ /** Numeric comparison of x.y.z[-pre]; a prerelease sorts below its release. */
12
+ export function compareReleaseVersions(left, right) {
13
+ const parse = value => {
14
+ const match = VERSION.exec(String(value ?? ''))
15
+ return match ? { parts: match.slice(1, 4).map(Number), pre: String(value).includes('-') } : null
16
+ }
17
+ const a = parse(left)
18
+ const b = parse(right)
19
+ if (!a || !b) return null
20
+ for (let index = 0; index < 3; index += 1) {
21
+ if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1
22
+ }
23
+ if (a.pre !== b.pre) return a.pre ? -1 : 1
24
+ return 0
25
+ }
26
+
27
+ /** True when both versions parse and the installed copy is strictly older. */
28
+ export function versionStillOlder(installedVersion, latestVersion) {
29
+ return compareReleaseVersions(installedVersion, latestVersion) === -1
30
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.3 — 2026-10-09
4
+
5
+ Stable patch under the npm `latest` tag. Fully quit Harness, including a WebUI server process or the desktop tray, and restart after upgrading so the Host reloads. Existing 0.16.2 artifacts remain unchanged.
6
+
7
+ - Windows `shell: true` spawns again pass the executable and arguments separately. 0.16.2 pre-quoted that command line; cmd then started Node on a missing script (`MODULE_NOT_FOUND`, empty `requireStack`, Node 24) for every official tool's probe, `npm config get prefix`, and `npm install -g`.
8
+ - A `--version` banner that contains a version counts as installed, including UTF-16 output from a native Windows executable. If the name on PATH fails, the absolute path from `where`/`which` is tried without a shell. One probe or readiness failure stays on that card.
9
+ - Package entry lookup accepts `./dist/main.mjs` and a `bin/` directory that is itself on PATH. A CLI that is already the latest release stays on “已是最新版本” instead of “修复官方执行入口”.
10
+ - The 0.16.2 update-button behavior remains: the click keeps its own job, refresh does not disable a visible update, and an install that leaves the probed version older fails with a row error.
11
+ - Release validation on Linux: 630 tests passed, 4 conditional skips, `npm run check:client` passed. No Harness WebUI, Harness Desktop, or Windows `cmd.exe` run was available in this environment.
12
+
13
+ ## 0.16.2 — 2026-10-09
14
+
15
+ Stable patch under the npm `latest` tag. Fully quit Harness, including the tray process, and restart after upgrading. Existing 0.16.1 artifacts remain unchanged.
16
+
17
+ - Official-tools update/install clicks keep the job started by that click. A status poll no longer replaces it with an older finished job or clears a failure.
18
+ - Refreshing an existing probe no longer disables the update button. A click that cannot start, and an install whose probed version is still older than latest, show an error on that row.
19
+ - Installed Claude Code runs `claude update` before the registry npm install. Installed Windows MiniMax runs the official installer. Other registry cards share the same click path and fail visibly when the probed version does not advance.
20
+ - Release validation: 628 tests passed, 4 conditional skips, client check passed. No paid calls or real CLI installs were part of this verification.
21
+
3
22
  ## 0.16.1 — 2026-10-08
4
23
 
5
24
  Stable maturity patch under the npm `latest` tag. Fully quit Harness, including the tray process, and restart after upgrading. Existing 0.16.0 artifacts remain unchanged.
@@ -2,7 +2,7 @@
2
2
 
3
3
  模型路由从 **0.12.0** 起独立发布;GAL 是新的 **0.1.0** 插件。两者均声明支持官方 **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**,可分别安装和更新。
4
4
 
5
- 当前路由正式版为 **0.16.1**。在既有动态公开数据和分任务类型反馈学习的基础上,本版完善反馈缓存/解释、异步刷新与设置保存,并保护不完整费用历史下的有限预算执行。公开数据默认关闭;主观偏好调整不改变公共能力评分或质量门槛。源码开发可使用独立模拟 UI 预览;安装时选择下方精确版本。
5
+ 当前路由正式版为 **0.16.3**。本版恢复全部官方工具卡片的检测与安装:Windows 上不再把每条探测和 `npm` 命令预先加引号,单张卡片的失败不会清空整页。0.16.2 的更新按钮修复和 0.16.1 的反馈缓存仍然保留。公开数据默认关闭;主观偏好调整不改变公共能力评分或质量门槛。源码开发可使用独立模拟 UI 预览;安装时选择下方精确版本。
6
6
 
7
7
  [路由 README](README.zh.md) · [迁移说明](MIGRATION.md) · [独立 GAL README](https://github.com/Alice-Marx/deepseek-harness-galgame#readme)
8
8
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  | 需求 | 添加插件输入框 | 安装后的入口 |
12
12
  | --- | --- | --- |
13
- | 分析复杂度、按质量和费用选模型、调用官方工具 | `@ljwei-stak/dsh-model-router@0.16.1` | 模型路由 |
13
+ | 分析复杂度、按质量和费用选模型、调用官方工具 | `@ljwei-stak/dsh-model-router@0.16.3` | 模型路由 |
14
14
  | 剧情、立绘、音乐、存档、自由对话 | `@ljwei-stak/dsh-galgame@0.1.0` | Gal 模块 |
15
15
  | 两者都要 | 分别安装上面两个包 | 模型路由 + Gal 模块 |
16
16
 
@@ -24,7 +24,7 @@
24
24
  2. 在“包名、GitHub 仓库地址或本地目录路径”输入框填一个完整包名,例如:
25
25
 
26
26
  ```text
27
- @ljwei-stak/dsh-model-router@0.16.1
27
+ @ljwei-stak/dsh-model-router@0.16.3
28
28
  ```
29
29
 
30
30
  只想安装 GAL 时填写:
@@ -40,7 +40,7 @@
40
40
  ```
41
41
 
42
42
  4. 核对预览的包名、版本和宿主兼容信息,安装并启用。完全退出 Harness(包括托盘进程)再启动,确保 Host 加载新代码。
43
- 5. 检查插件详情与侧边栏:路由应为 **0.16.1 / 模型路由**;GAL 应为 **0.1.0 / Gal 模块**。仅装路由时没有 GAL 入口是预期行为。
43
+ 5. 检查插件详情与侧边栏:路由应为 **0.16.3 / 模型路由**;GAL 应为 **0.1.0 / Gal 模块**。仅装路由时没有 GAL 入口是预期行为。
44
44
 
45
45
  普通用户无需 `npm install -g`,该命令不会把插件注册到当前 Harness profile。
46
46
 
@@ -48,13 +48,13 @@
48
48
 
49
49
  进入对应 Release 下载 `.tgz`:
50
50
 
51
- - [模型路由 Releases](https://github.com/Alice-Marx/dsh-model-router/releases)(也可运行 `npm pack @ljwei-stak/dsh-model-router@0.16.1` 获取同一安装包)
51
+ - [模型路由 Releases](https://github.com/Alice-Marx/dsh-model-router/releases)(也可运行 `npm pack @ljwei-stak/dsh-model-router@0.16.3` 获取同一安装包)
52
52
  - [独立 GAL v0.1.0](https://github.com/Alice-Marx/deepseek-harness-galgame/releases/tag/v0.1.0)
53
53
 
54
54
  在“添加插件”填写下载文件的**绝对路径**,例如:
55
55
 
56
56
  ```text
57
- D:\Plugins\ljwei-stak-dsh-model-router-0.16.1.tgz
57
+ D:\Plugins\ljwei-stak-dsh-model-router-0.16.3.tgz
58
58
  ```
59
59
 
60
60
  ```text
@@ -64,7 +64,7 @@ D:\Plugins\ljwei-stak-dsh-galgame-0.1.0.tgz
64
64
  如附有 `.sha256` 文件,用 PowerShell 计算并比较摘要:
65
65
 
66
66
  ```powershell
67
- Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-model-router-0.16.1.tgz'
67
+ Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-model-router-0.16.3.tgz'
68
68
  Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-galgame-0.1.0.tgz'
69
69
  ```
70
70
 
@@ -74,7 +74,7 @@ Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-galgame-0
74
74
 
75
75
  1. 在旧合并插件的 GAL 播放器导出需要保留的剧情进度,并备份整个 Harness profile。每份 JSON 仅保存**当前剧情状态**,不含所有手动槽、设置、已读记录或本地音乐;重要槽位可依次读档再导出。
76
76
  2. 在**同一个 profile**安装独立 `@ljwei-stak/dsh-galgame@0.1.0`。
77
- 3. 0.13.0 起包名改为 `@ljwei-stak/dsh-model-router`(旧包名最后版本为 0.13.0,内容相同,之后不再更新):在插件管理器卸载原 `@ljwei-stak/model-router-galgame`(保留 profile 与应用数据),再添加 `@ljwei-stak/dsh-model-router@0.16.1`。不要两个同时安装(条目 id 与工具名相同);条目 id 未变,路由设置与执行记录会保留。完成两项更新后再玩剧情,避免同时操作旧合并入口和新入口。
77
+ 3. 0.13.0 起包名改为 `@ljwei-stak/dsh-model-router`(旧包名最后版本为 0.13.0,内容相同,之后不再更新):在插件管理器卸载原 `@ljwei-stak/model-router-galgame`(保留 profile 与应用数据),再添加 `@ljwei-stak/dsh-model-router@0.16.3`。不要两个同时安装(条目 id 与工具名相同);条目 id 未变,路由设置与执行记录会保留。完成两项更新后再玩剧情,避免同时操作旧合并入口和新入口。
78
78
  4. 现在路由与 GAL 各有一个入口。独立 GAL 沿用旧存档 localStorage 键,核对当前进度和三槽;更换 profile 或进度未恢复时,先切换到匹配剧目,再导入 JSON 备份。
79
79
  5. 后续分别更新需要的插件;只用路由可以不启用或不安装 GAL。保留 profile 与应用数据,卸载插件时不要清理故事数据。
80
80
 
@@ -82,7 +82,7 @@ GAL 核心只包含**《回声之城·正篇》**与**《旧城迁移篇:未
82
82
 
83
83
  ## 5. 模型路由第一次使用
84
84
 
85
- 工作台自 0.15.0 起分成**任务与执行、模型配置、官方工具、预算与安全**四页;0.16.1 沿用该布局。切换页面保留安装任务、终端会话和未完成的输入。
85
+ 工作台自 0.15.0 起分成**任务与执行、模型配置、官方工具、预算与安全**四页;0.16.3 沿用该布局。切换页面保留安装任务、终端会话和未完成的输入。
86
86
 
87
87
  1. 在 Harness 官方**模型**页配置供应商、模型与凭据。插件读取准确的 `provider/model` 目录,模型档案不保存 API Key。
88
88
  2. 在**模型配置**刷新目录,逐条填写自报质量评分、核对过的输入/输出单价(USD / 百万 token)和擅长方向;缺价留空。可选 `cliModel` 必须是该厂商 CLI 已验证接受的名字。可选 LiveBench 名称须准确对应版本与推理档位;它是用户确认的映射,不自动证明实际执行档位。
@@ -114,7 +114,7 @@ GAL 核心只包含**《回声之城·正篇》**与**《旧城迁移篇:未
114
114
  | 检查 | 预期 |
115
115
  | --- | --- |
116
116
  | 宿主版本 | 0.2.0-rc.1 或 0.2.0-rc.2;包括 `rc` 后缀。 |
117
- | 路由独立安装 | 0.16.1 详情、模型路由入口、目录和模型档案可用;不出现旧合并 GAL 入口。 |
117
+ | 路由独立安装 | 0.16.3 详情、模型路由入口、目录和模型档案可用;不出现旧合并 GAL 入口。 |
118
118
  | GAL 独立安装 | 0.1.0 详情、Gal 模块入口、标题与两部核心剧情可用;不要求安装路由。 |
119
119
  | 共同安装 | 两个入口各一份,路由工具和 GAL 播放器均能使用。 |
120
120
  | 存档迁移 | 同 profile 可读旧键;跨 profile 用匹配剧目 JSON 导入;旧篇目的数据不覆盖。 |
@@ -125,11 +125,11 @@ GAL 核心只包含**《回声之城·正篇》**与**《旧城迁移篇:未
125
125
  | 动态公开数据 | 默认关闭;自行启用后核对快照版本、校验时间及失败/过期状态;人工单价仍优先。 |
126
126
  | 可编辑执行 | 在干净测试仓库核对审批、隔离工作区、补丁整合、厂商实际模型与计费。 |
127
127
 
128
- 历史 0.15.0 的自动测试验证为 220 项通过、3 项 Windows 平台跳过;组件 UI 预览检查使用模拟数据。**0.16.1 的发布验证见[更新记录](CHANGELOG.md)**,不把模拟公开源/模型流和组件预览当作真实付费调用或 Harness Desktop 端到端验收;上表中的实际安装、账号和工作区检查仍需在使用环境中完成。
128
+ 历史 0.15.0 的自动测试验证为 220 项通过、3 项 Windows 平台跳过;组件 UI 预览检查使用模拟数据。**0.16.3 的发布验证见[更新记录](CHANGELOG.md)**,不把模拟公开源/模型流和组件预览当作真实付费调用或 Harness Desktop 端到端验收;上表中的实际安装、账号和工作区检查仍需在使用环境中完成。
129
129
 
130
130
  ## 8. 开发者从完整源码构建
131
131
 
132
- 路由需要 Node.js **22.19+** 与 **pnpm 10.34.6**。从完整源码仓库根目录构建;复现正式版 0.16.1 时检出对应发布标签。旧版(如 0.15.0)标签保留其当时内容。运行:
132
+ 路由需要 Node.js **22.19+** 与 **pnpm 10.34.6**。从完整源码仓库根目录构建;复现正式版 0.16.3 时检出对应发布标签。旧版(如 0.15.0)标签保留其当时内容。运行:
133
133
 
134
134
  ```powershell
135
135
  pnpm install --frozen-lockfile --strict-peer-dependencies
package/README.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # Model Router · DeepSeek Harness Desktop
2
2
 
3
- Version: **0.16.1**. See [routing research](docs/ROUTING_RESEARCH.zh.md), [mathematical derivation and experiments](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md), and the [LiveBench evidence audit and validation protocol](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md). The latest public 2026-06-25 score/cost tables are aggregates; its paired question-quality/cost matrix has not been obtained, and no real routing gain is claimed.
3
+ Version: **0.16.3**. See [routing research](docs/ROUTING_RESEARCH.zh.md), [mathematical derivation and experiments](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md), and the [LiveBench evidence audit and validation protocol](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md). The latest public 2026-06-25 score/cost tables are aggregates; its paired question-quality/cost matrix has not been obtained, and no real routing gain is claimed.
4
4
 
5
5
  A cost-aware model-routing workbench for **DeepSeek Harness Desktop**. Describe a task, compare your configured models, inspect the plan, and explicitly choose when to execute it.
6
6
 
7
+ **0.16.3:** restores official-tools detection and install for every registry card. Windows probes and `npm` are spawned with separate arguments again, so cmd no longer launches Node against a missing script. A version banner still counts as installed, one card's probe error stays on that row, and a CLI that is already current is not sent through “修复官方执行入口”.
8
+
9
+ **0.16.2:** the official-tools update/install button keeps the job started by that click, stays usable while a previous probe is refreshing, and shows a row error when the click cannot start or the probed version does not advance. Installed Claude Code runs `claude update` before the registry npm install. Installed Windows MiniMax runs the official installer.
10
+
7
11
  **0.16.1:** [maturity improvements and validation](docs/ROUTING_MATURITY.zh.md) adds stable feedback cache identity, per-model/task feedback explanations, source-refresh and settings-race protection, and budget gates for unreadable or incomplete cost history. Mechanism tests do not establish real quality–cost gains.
8
12
 
9
13
  **0.16.0:** [dynamic data and adaptive feedback](docs/ROUTING_ADAPTIVE_LEARNING.zh.md) adds opt-in public benchmark/curated fixed-USD pricing refresh and task-specific, time-decayed, shrinkage-regularized subjective utility. Objective proxy quality floors remain separate. Public sources default off; preferences are shared within one local DSH_HOME and limited to the latest 200 runs. This is not a universal vendor-price scraper or evidence of real quality–cost gains.
10
14
 
11
- Current version: **0.16.1** (`@ljwei-stak/dsh-model-router`). Its declared host compatibility is **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**. The router and [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) have been independent plugins since 0.12.0.
15
+ Current version: **0.16.3** (`@ljwei-stak/dsh-model-router`). Its declared host compatibility is **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**. The router and [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) have been independent plugins since 0.12.0.
12
16
 
13
17
  **0.15.0 changes:** a four-tab workbench with routing advice directly below task planning, protection against stale execution previews, preserved spending totals across history pruning and retries, and a standalone mock UI preview for source development.
14
18
 
@@ -40,16 +44,16 @@ Generating a route recommendation is local: it does not start a vendor CLI or ma
40
44
  Open **Plugins → Add plugin** and enter:
41
45
 
42
46
  ```text
43
- @ljwei-stak/dsh-model-router@0.16.1
47
+ @ljwei-stak/dsh-model-router@0.16.3
44
48
  ```
45
49
 
46
- Use the official HTTPS npm source `https://registry.npmjs.org/` when a mirror lacks that exact version. Enable the plugin, then fully quit Harness (including its tray process) and restart it. Plugin details should show **0.16.1** and the sidebar should contain **模型路由** (Model Router). A global `npm install -g` alone does not register a Desktop plugin.
50
+ Use the official HTTPS npm source `https://registry.npmjs.org/` when a mirror lacks that exact version. Enable the plugin, then fully quit Harness (including its tray process) and restart it. Plugin details should show **0.16.3** and the sidebar should contain **模型路由** (Model Router). A global `npm install -g` alone does not register a Desktop plugin.
47
51
 
48
52
  You can instead install a local `.tgz` archive or its extracted inner `package` directory through the plugin manager. Both must contain `package.json` and `.dsh-plugin`. See [releases](https://github.com/Alice-Marx/dsh-model-router/releases) for archived builds, or build your own checkout below.
49
53
 
50
54
  ### Upgrade from the old package name
51
55
 
52
- The old name `@ljwei-stak/model-router-galgame` ended at 0.13.0. Remove that plugin entry while retaining your profile/application data, then add `@ljwei-stak/dsh-model-router@0.16.1`. Do not keep both entries installed: they share the internal plugin id `model-router-galgame` and tool names. The retained id lets existing router settings and history carry over.
56
+ The old name `@ljwei-stak/model-router-galgame` ended at 0.13.0. Remove that plugin entry while retaining your profile/application data, then add `@ljwei-stak/dsh-model-router@0.16.3`. Do not keep both entries installed: they share the internal plugin id `model-router-galgame` and tool names. The retained id lets existing router settings and history carry over.
53
57
 
54
58
  For the combined 0.11.x package, back up the profile and export the GAL saves you want to preserve before upgrading. Story playback, artwork, music and saves now belong to the independent `@ljwei-stak/dsh-galgame` package. The detailed save limitations are in [MIGRATION.md](MIGRATION.md).
55
59
 
package/README.zh.md CHANGED
@@ -1,14 +1,18 @@
1
1
  # Model Router · DeepSeek Harness 模型路由插件
2
2
 
3
- 版本:**0.16.1**。选模求解器概览见[路由研究说明](docs/ROUTING_RESEARCH.zh.md);公式、条件证明与实验见[数学推导与实验记录](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md)。[LiveBench 最新证据与验证协议](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md)已核查 2026-06-25 聚合评分/费用;最新逐题质量—费用矩阵尚未取得,不声称真实路由收益。
3
+ 版本:**0.16.3**。选模求解器概览见[路由研究说明](docs/ROUTING_RESEARCH.zh.md);公式、条件证明与实验见[数学推导与实验记录](docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md)。[LiveBench 最新证据与验证协议](docs/ROUTING_LIVEBENCH_VALIDATION.zh.md)已核查 2026-06-25 聚合评分/费用;最新逐题质量—费用矩阵尚未取得,不声称真实路由收益。
4
4
 
5
5
  为 **DeepSeek Harness Desktop** 提供任务规划、成本感知的模型分配和官方工具执行工作台。输入任务,比较已配置模型,审阅计划,再明确选择是否执行。
6
6
 
7
+ **0.16.3**:恢复全部官方工具卡片的检测和安装。Windows 上的探测与 `npm` 再次按独立参数启动,避免 cmd 把 Node 指到不存在的脚本。只要输出版本号就视为已安装;单张卡片的检测错误留在该行;已经是最新版的 CLI 不再被收成「修复官方执行入口」。
8
+
9
+ **0.16.2**:官方工具的更新/安装按钮会保留本次点击启动的任务;已有探测结果时重新体检不再把按钮禁用成无响应;点击无法开始、或安装后探测版本仍低于最新版时,在该行显示错误。已安装的 Claude Code 先执行 `claude update`,再按注册表走 npm;已安装的 Windows MiniMax 走官方安装脚本。
10
+
7
11
  **0.16.1 成熟度改进**:稳定反馈缓存、分模型/任务解释、刷新与设置竞态保护,以及费用历史不可读取或不完整时的预算门禁。[推导与验证](docs/ROUTING_MATURITY.zh.md)随安装包提供,机制测试不等于真实质量—成本收益。
8
12
 
9
13
  **0.16.0 新增**:[动态数据与持续反馈](docs/ROUTING_ADAPTIVE_LEARNING.zh.md):可选公开价格/LiveBench快照刷新;按任务类型、时间衰减和低样本收缩调整个人偏好,质量门槛独立保留。设置在“预算与安全”,公开源默认关闭;价格源需提供按官方文档核对的固定USD单价JSON,不是通用官网抓价器。本地DSH_HOME共享、最近200运行窗口,暂无真实收益证明。
10
14
 
11
- 当前版本为 **0.16.1**,包名为 `@ljwei-stak/dsh-model-router`;声明兼容的宿主为 **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**。自 0.12.0 起,路由与 [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) 已拆成两个独立插件。
15
+ 当前版本为 **0.16.3**,包名为 `@ljwei-stak/dsh-model-router`;声明兼容的宿主为 **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**。自 0.12.0 起,路由与 [GAL](https://github.com/Alice-Marx/deepseek-harness-galgame) 已拆成两个独立插件。
12
16
 
13
17
  **0.15.0 更新**:工作台分为四页,路由建议紧接任务规划展示;执行输入变化时丢弃旧预览;裁剪历史或重试后仍保留费用累计;源码开发新增独立模拟 UI 预览。
14
18
 
@@ -40,16 +44,16 @@
40
44
  打开 **插件 → 添加插件**,填写:
41
45
 
42
46
  ```text
43
- @ljwei-stak/dsh-model-router@0.16.1
47
+ @ljwei-stak/dsh-model-router@0.16.3
44
48
  ```
45
49
 
46
- 镜像缺少该精确版本时使用官方 HTTPS npm 源 `https://registry.npmjs.org/`。安装并启用后,完全退出 Harness(包括托盘进程)再启动。插件详情应显示 **0.16.1**,侧栏出现**模型路由**。仅运行全局 `npm install -g` 不会把插件注册到桌面版 profile。
50
+ 镜像缺少该精确版本时使用官方 HTTPS npm 源 `https://registry.npmjs.org/`。安装并启用后,完全退出 Harness(包括托盘进程)再启动。插件详情应显示 **0.16.3**,侧栏出现**模型路由**。仅运行全局 `npm install -g` 不会把插件注册到桌面版 profile。
47
51
 
48
52
  也可在插件管理器填写本地 `.tgz` 文件的绝对路径,或解压后的内层 `package` 目录;其中应包含 `package.json` 与 `.dsh-plugin`。[历史发布页](https://github.com/Alice-Marx/dsh-model-router/releases)提供归档;已克隆源码的开发方式见下文。
49
53
 
50
54
  ### 从旧包名升级
51
55
 
52
- `@ljwei-stak/model-router-galgame` 的最后版本为 0.13.0。保留 profile 与应用数据,移除旧插件条目,再添加 `@ljwei-stak/dsh-model-router@0.16.1`。不要同时安装两个包:它们共用内部插件 id `model-router-galgame` 与工具名;id 保留使已有路由设置和执行历史能够延续。
56
+ `@ljwei-stak/model-router-galgame` 的最后版本为 0.13.0。保留 profile 与应用数据,移除旧插件条目,再添加 `@ljwei-stak/dsh-model-router@0.16.3`。不要同时安装两个包:它们共用内部插件 id `model-router-galgame` 与工具名;id 保留使已有路由设置和执行历史能够延续。
53
57
 
54
58
  从 0.11.x 合并版迁移时,先备份 profile 并导出希望保留的 GAL 进度。剧情、立绘、音乐、自由模式和存档属于独立 `@ljwei-stak/dsh-galgame` 插件。导出范围及同 profile 迁移的限制见 [MIGRATION.md](MIGRATION.md)。
55
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ljwei-stak/dsh-model-router",
3
- "version": "0.16.1",
3
+ "version": "0.16.3",
4
4
  "type": "module",
5
5
  "description": "Model Router for official DeepSeek Harness Desktop: cost-aware task routing, dependent work packages and official vendor CLI discovery, installation and execution. GAL is available separately as @ljwei-stak/dsh-galgame.",
6
6
  "repository": {
@@ -57,6 +57,7 @@
57
57
  ".dsh-plugin/shared/vendor-minimax-adapter.mjs",
58
58
  ".dsh-plugin/shared/vendor-mimo-grok-adapter.mjs",
59
59
  ".dsh-plugin/shared/latest-versions.mjs",
60
+ ".dsh-plugin/shared/version-order.mjs",
60
61
  ".dsh-plugin/shared/npm-attestation.mjs",
61
62
  ".dsh-plugin/shared/zcode-bundle.mjs",
62
63
  ".dsh-plugin/shared/zcode-installer.mjs",