dshmarket 1.66.14 → 1.66.15

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/client/client.js CHANGED
@@ -642,6 +642,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
642
642
  stateIncompatible: "已安装,与当前 dsh 不兼容",
643
643
  stateInert: "已安装,未生效",
644
644
  stateDependencyLibrary: "{0} 的依赖库",
645
+ stateLoadedByProfile: "由你的配置加载",
645
646
  stateBroken: "已安装,校验未通过",
646
647
  stateDisabled: "已停用",
647
648
  phaseResolving: "准备依赖",
@@ -1338,6 +1339,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1338
1339
  stateIncompatible: "Installed — incompatible with this dsh",
1339
1340
  stateInert: "Installed, not active",
1340
1341
  stateDependencyLibrary: "Library for {0}",
1342
+ stateLoadedByProfile: "Loaded by your setup",
1341
1343
  stateBroken: "Installed, verification failed",
1342
1344
  stateDisabled: "Disabled",
1343
1345
  phaseResolving: "Preparing dependencies",
@@ -1917,15 +1919,19 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1917
1919
  return normalized;
1918
1920
  }
1919
1921
  /**
1920
- * Relevance within one field. Exact and prefix matches beat phrase matches;
1921
- * for a multi-word query every word must occur in the same field.
1922
+ * Relevance within one field. Exact matches beat phrase matches, which beat
1923
+ * words scattered through the field; for a multi-word query every word must
1924
+ * occur in the same field. A prefix match is NOT ranked above a phrase match
1925
+ * anywhere else in the field: most catalog names start with `dsh-`, so that
1926
+ * distinction mostly punished the naming convention and overrode the sort
1927
+ * the user picked (#815: `computer-use-vision` with 355 downloads above
1928
+ * `dsh-computer-use` with 3.6k, under "downloads").
1922
1929
  */
1923
1930
  function fieldRelevance(plugin, value, query, tokens, weight) {
1924
1931
  if (!value) return 0;
1925
1932
  const text = cachedPluginSearchText(plugin, value);
1926
1933
  if (text === "" || !tokens.every((token) => text.includes(token))) return 0;
1927
1934
  if (text === query) return weight + 300;
1928
- if (text.startsWith(query)) return weight + 250;
1929
1935
  if (text.includes(query)) return weight + 200;
1930
1936
  return weight + 150;
1931
1937
  }
@@ -6321,7 +6327,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6321
6327
  });
6322
6328
  }
6323
6329
  /** The state label + dot for one activation result (P0-2). */
6324
- function activationMeta(state, t, dependencyOf) {
6330
+ function activationMeta(state, t, dependencyOf, loadedByProfile) {
6331
+ if (state === "inert" && loadedByProfile === true) return {
6332
+ label: t("stateLoadedByProfile"),
6333
+ dot: "done"
6334
+ };
6325
6335
  if (state === "inert" && dependencyOf !== void 0) return {
6326
6336
  label: t("stateDependencyLibrary").replace("{0}", dependencyOf),
6327
6337
  dot: "done"
@@ -9104,13 +9114,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9104
9114
  return;
9105
9115
  }
9106
9116
  if (status === 200 && body.ok) {
9107
- sessionStorage.setItem("dshm-tab", "installed");
9108
9117
  if (body.activation && typeof body.activation === "object") {
9109
9118
  setActivations((prev) => ({
9110
9119
  ...prev,
9111
9120
  ...body.activation
9112
9121
  }));
9113
- const warns = Object.entries(body.activation).filter(([, info]) => info.state !== "live" && info.state !== "missing").map(([name, info]) => ({
9122
+ const warns = Object.entries(body.activation).filter(([, info]) => info.state !== "live" && info.state !== "missing" && info.loadedByProfile !== true).map(([name, info]) => ({
9114
9123
  name,
9115
9124
  info
9116
9125
  }));
@@ -11891,7 +11900,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11891
11900
  children: activationWarnings.map(({ name, info }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
11892
11901
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: name }),
11893
11902
  " — ",
11894
- activationMeta(info.state, t, info.dependencyOf).label,
11903
+ activationMeta(info.state, t, info.dependencyOf, info.loadedByProfile).label,
11895
11904
  info.reasons.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
11896
11905
  className: Market_module_css_default.spec,
11897
11906
  children: [
@@ -12845,11 +12854,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12845
12854
  const entry = data === null ? void 0 : catalogEntryForInstalled(data.plugins, name, String(installed[name]), repoIdentities[name], repoHints[name]);
12846
12855
  const off = effectiveDisabledSet.has(name);
12847
12856
  const act = activations[name];
12848
- const meta = !off && act !== void 0 ? activationMeta(act.state, t, act.dependencyOf) : null;
12857
+ const meta = !off && act !== void 0 ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null;
12849
12858
  const note = notes[name];
12850
12859
  const authored = entry?.description && (entry.description[lang] || entry.description.en) || "";
12851
12860
  const shown = note ?? authored;
12852
- const stateLabel = off ? t("disabledState") : act?.state === "inert" && act.dependencyOf === void 0 ? t("groupStateInert") : act?.state === "restart" ? t("groupStateRestart") : act?.state === "broken" ? t("groupStateBroken") : meta?.label;
12861
+ const stateLabel = off ? t("disabledState") : act?.state === "inert" && act.dependencyOf === void 0 && act.loadedByProfile !== true ? t("groupStateInert") : act?.state === "restart" ? t("groupStateRestart") : act?.state === "broken" ? t("groupStateBroken") : meta?.label;
12853
12862
  const stateDot = off ? "warning" : meta?.dot === "error" ? "error" : meta?.dot === "warning" ? "warning" : "done";
12854
12863
  const stateClass = stateDot === "error" ? Market_module_css_default.actBroken : stateDot === "warning" ? Market_module_css_default.actWarn : Market_module_css_default.actLive;
12855
12864
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -12953,7 +12962,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12953
12962
  const generation = status?.kind === "generation" || isGenerationSpec(String(spec));
12954
12963
  const localDev = isLocalDev(String(spec), status);
12955
12964
  const act = activations[name];
12956
- const meta = act !== void 0 ? activationMeta(act.state, t, act.dependencyOf) : null;
12965
+ const meta = act !== void 0 ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null;
12957
12966
  const version = status && status.version ? "v" + status.version : "";
12958
12967
  const specText = String(spec);
12959
12968
  const specRedundant = version !== "" && /^[\^~]?\d/.test(specText);
package/lib/index.js CHANGED
@@ -300,6 +300,9 @@ export function apply(ctx, config) {
300
300
  // capability bits report that, and an explicit profile directory no
301
301
  // longer implies it (#639).
302
302
  desktopHost: true,
303
+ // A shell that cannot be asked to replay still replays on its own,
304
+ // shortly after the install lands (#814): wait for it and adopt.
305
+ ...(desktopProfiles.pluginActivation === undefined ? { hostReplayWaitMs: 8000 } : {}),
303
306
  // Relaunching a raw Electron process would bypass Desktop's launcher
304
307
  // lifecycle. The shell remains responsible for restart in this mode.
305
308
  allowRestart: false,
@@ -147,6 +147,26 @@ function integrityViolators(diagnostic) {
147
147
  * @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
148
148
  */
149
149
  export function classifyPnpmFailure(output, exitCode) {
150
+ // #817: the host's silence watchdog (@deepseek-ai/dsh-plugin-manager,
151
+ // `idleTimeoutMs`, 10 minutes by default) killed pnpm. Checked first: it is
152
+ // the last thing that happened, so whatever pnpm printed before is not the
153
+ // cause. The usual culprit is a dependency's install script downloading a
154
+ // binary from a host this network cannot reach — the reporter's cloudflared
155
+ // postinstall fetching from GitHub Releases — so the last lifecycle line
156
+ // pnpm printed names the package that went quiet.
157
+ const stalled = /printed nothing for (\d+)ms and was terminated/.exec(output);
158
+ if (stalled !== null) {
159
+ const minutes = Math.max(1, Math.round(Number(stalled[1]) / 60000));
160
+ const scripts = [...output.matchAll(/node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s]+) (?:pre|post)?install[:$]/g)];
161
+ const culprit = scripts.at(-1)?.[1]?.replace('\\', '/');
162
+ return {
163
+ code: 'pnpm-stalled',
164
+ recoverable: false,
165
+ message: culprit !== undefined
166
+ ? `依赖 ${culprit} 的安装脚本 ${minutes} 分钟没有任何输出,被终止了,什么都没装上。这类脚本通常在从网上下载文件,多半是当前网络连不上下载地址;插件本身没有问题。换个网络或开启代理后重试;仍然不行就联系插件作者。 / the install script of the dependency ${culprit} printed nothing for ${minutes} minutes and was stopped, so nothing was installed. Scripts like this usually download a file, and this network most likely cannot reach where it comes from; the plugin itself is not at fault. Try again on another network or through a proxy, and contact the plugin's author if it still fails.`
167
+ : `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
168
+ };
169
+ }
150
170
  if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
151
171
  || output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
152
172
  return {
@@ -456,6 +476,59 @@ export function classifyPnpmFailure(output, exitCode) {
456
476
  : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
457
477
  };
458
478
  }
479
+ // #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
480
+ // two shapes that pnpm reports with two codes and two different repairs, so
481
+ // they are classified apart rather than merged into one answer.
482
+ //
483
+ // `lockfile-outdated` is tested first, because its anchor is a code and
484
+ // nothing else. The sentence pnpm prints here also arrives WRAPPED across
485
+ // lines in the human reporter ("… is not up" / "to date with
486
+ // package.json."), so keying on that sentence instead of the code would miss
487
+ // the form users actually see.
488
+ //
489
+ // The two failures do not happen in a fixed order, so nothing here — and
490
+ // nothing in either message — claims which one a user meets first.
491
+ //
492
+ // `recoverable: false` on both: repeating the same command cannot help, the
493
+ // lockfile has to change first.
494
+ if (output.includes('ERR_PNPM_OUTDATED_LOCKFILE')) {
495
+ return {
496
+ code: 'lockfile-outdated',
497
+ recoverable: false,
498
+ message: 'profile 的 pnpm-lock.yaml 与 package.json 对不上:锁文件里记的 specifier 和 manifest 里声明的不是同一个,pnpm 因此在 frozen-lockfile 这一步直接拒绝,什么都没装。先备份一份 pnpm-lock.yaml,再在 profile 目录里重新生成锁文件(pnpm 最后一行会给出它自己那一版的命令;pnpm install --lockfile-only 也让锁文件与 package.json 对齐)后重试。这与「锁文件里缺条目」是两个不同的报错、对应两种不同的修法,两条命令不要互相套用(#808) / this profile\'s pnpm-lock.yaml does not match package.json: the specifier recorded in the lockfile is not the one the manifest declares, so pnpm refuses at the frozen-lockfile check and installs nothing. Back up pnpm-lock.yaml first, then regenerate the lockfile in the profile directory (pnpm\'s last line names the command for its own version; pnpm install --lockfile-only also realigns the lockfile with package.json) and retry. This is a different error with a different repair from a lockfile that is missing an entry — do not apply one repair to the other',
499
+ };
500
+ }
501
+ // `lockfile-broken` is the lockfile contradicting ITSELF: an importer names
502
+ // a dependency that has no entry under `packages:`. pnpm 11 and 12 word it
503
+ // identically and both carry the code, so the rule keys on the code and on
504
+ // pnpm's own phrase — and deliberately NOT on pnpm's closing `help:` line,
505
+ // which drifts: 11.24.0 offers `pnpm install --no-frozen-lockfile` for this
506
+ // failure while 12.8.1 offers `pnpm install --fix-lockfile` (and for the
507
+ // outdated failure above it offers a third command again, `--lockfile-only`).
508
+ // Measured 2026-10-08 against 11.24.0 and 12.8.1.
509
+ //
510
+ // Both shapes reach here whichever stream pnpm used. Market commands run with
511
+ // `--reporter=ndjson`, and in that same measurement 11.24.0 wrote the error
512
+ // record to stdout as one JSON line while 12.8.1 wrote nothing to stdout at
513
+ // all (0 bytes) and put the error block on stderr as plain text.
514
+ // withDecodedPnpmDiagnostics lifts the message out of the first form, and
515
+ // both forms then carry these two strings verbatim.
516
+ //
517
+ // The missing entry is named in the message but deliberately NOT put in
518
+ // `pkg`: callers read `pkg` as "the package this failure is about" and act on
519
+ // it (the #289 peer retry, the #65 ghost-entry advice), and neither applies —
520
+ // the repair is in the lockfile, not in the manifest entry.
521
+ if (output.includes('ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY') || /Broken lockfile/.test(output)) {
522
+ const broken = /no entry for '?([^'\s]+@[^'\s]+)'? in pnpm-lock\.yaml/i
523
+ .exec(withDecodedPnpmDiagnostics(output))?.[1];
524
+ const zh = broken === undefined ? '' : `(${broken})`;
525
+ const en = broken === undefined ? '' : ` (${broken})`;
526
+ return {
527
+ code: 'lockfile-broken',
528
+ recoverable: false,
529
+ message: `profile 的 pnpm-lock.yaml 自相矛盾:有个依赖在 importers 里记着,packages 里却没有它的条目${zh},pnpm 因此在真正写 node_modules 的那一步拒绝整个命令。先备份一份 pnpm-lock.yaml;在 importers 下删掉这个依赖的那条记录(它的名字、specifier、version 三行),再在 profile 目录里执行 pnpm install --no-frozen-lockfile --lockfile-only,然后重试。不要用 pnpm 提示的 --fix-lockfile:在 12.x 上它修不好这种状态(11.x 上能修,所以那条提示只在 12.x 的 help 里出现,照它走会把 12.x 用户送到一条一定失败的命令上)。不要删掉整个 pnpm-lock.yaml,那会把其余插件的版本一起重新解析(#808) / this profile\'s pnpm-lock.yaml contradicts itself: a dependency is recorded in importers but has no entry under packages${en}, so pnpm refuses the whole command at the step that actually writes node_modules. Back up pnpm-lock.yaml first; remove that dependency\'s record from importers (its name, specifier and version lines); then run pnpm install --no-frozen-lockfile --lockfile-only in the profile directory and retry. Do not use the --fix-lockfile that pnpm itself suggests: on 12.x it does not repair this state (it does on 11.x, which is why that suggestion only appears in 12.x\'s help, and following it sends a 12.x user to a command that always fails). Do not delete the whole pnpm-lock.yaml either: that re-resolves every other plugin\'s version too (#808)`,
530
+ };
531
+ }
459
532
  // #786 follow-up: pnpm could not replace one of the profile's own files —
460
533
  // `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
461
534
  // `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
package/lib/profile.js CHANGED
@@ -1351,27 +1351,39 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1351
1351
  catch {
1352
1352
  return [];
1353
1353
  }
1354
- // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1355
- const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
1354
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines, with
1355
+ // comment lines allowed among them. Users annotate why a version is exempt,
1356
+ // and a comment line used to end the block — or veto the whole repair — so
1357
+ // the very profiles someone had tended by hand stayed broken (#816).
1358
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:(?:[ \t]+-[^\r\n]*|[ \t]*#[^\r\n]*)\r?\n?)*)/m;
1356
1359
  const block = blockRe.exec(yaml);
1357
1360
  if (block === null)
1358
1361
  return [];
1359
1362
  const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
1360
- const indentMatch = /^([ \t]+)-/.exec(block[1]);
1363
+ const indentMatch = /^([ \t]+)-/m.exec(block[1]);
1361
1364
  const indent = indentMatch === null ? ' ' : indentMatch[1];
1362
- const entries = [];
1365
+ const items = [];
1363
1366
  for (const line of block[1].split(/\r?\n/)) {
1364
1367
  if (line.trim() === '')
1365
1368
  continue;
1366
- const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
1367
- // A `#` on the line is a comment this cannot re-emit without losing it.
1368
- if (m === null || m[1].includes('#'))
1369
+ if (/^[ \t]*#/.test(line)) {
1370
+ items.push({ kind: 'comment', line });
1371
+ continue;
1372
+ }
1373
+ const m = /^([ \t]+-[ \t]*)(.*?)[ \t]*$/.exec(line);
1374
+ if (m === null)
1369
1375
  return [];
1370
- const rule = splitReleaseAgeExclude(m[1]);
1376
+ const commentAt = /[ \t]+#/.exec(m[2]);
1377
+ const value = commentAt === null ? m[2] : m[2].slice(0, commentAt.index);
1378
+ const comment = commentAt === null ? null : m[2].slice(commentAt.index).trim();
1379
+ if (value.includes('#'))
1380
+ return [];
1381
+ const rule = splitReleaseAgeExclude(value);
1371
1382
  if (rule === null)
1372
1383
  return [];
1373
- entries.push({ rule, quoted: /^['"]/.test(m[1]), line });
1384
+ items.push({ kind: 'entry', rule, quoted: /^['"]/.test(value), bare: `${m[1]}${value}`, comment });
1374
1385
  }
1386
+ const entries = items.flatMap(item => item.kind === 'entry' ? [{ ...item, line: item.bare }] : []);
1375
1387
  const byName = new Map();
1376
1388
  const order = [];
1377
1389
  for (const { rule, quoted, line } of entries) {
@@ -1418,7 +1430,18 @@ export function mergeDuplicateReleaseAgeExcludes(profile, explicitDir) {
1418
1430
  });
1419
1431
  if (rewritten.length === 0)
1420
1432
  return [];
1421
- const lines = order.map(produced);
1433
+ // Each package's one line goes where its FIRST rule was. A later duplicate
1434
+ // is dropped, but a comment it carried stays, as a comment line in its place.
1435
+ const written = new Set();
1436
+ const lines = items.flatMap(item => {
1437
+ if (item.kind === 'comment')
1438
+ return [item.line];
1439
+ const suffix = item.comment === null ? '' : ` ${item.comment}`;
1440
+ if (written.has(item.rule.name))
1441
+ return item.comment === null ? [] : [`${indent}${item.comment}`];
1442
+ written.add(item.rule.name);
1443
+ return [`${produced(item.rule.name)}${suffix}`];
1444
+ });
1422
1445
  const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
1423
1446
  // A function replacement: `$&` and friends in a string replacement would
1424
1447
  // be read as capture references.
package/lib/routes.js CHANGED
@@ -42,7 +42,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
42
42
  import { createThemeManager } from './themes.js';
43
43
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
44
44
  import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
45
- import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
45
+ import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
46
46
  import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
47
47
  import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
48
48
  import { createGist, fitsGistLimit, GistError, gistErrorCode, parseGistId, readGist, resolveGistTokenSource, updateGist, verifyGistToken, } from './gist.js';
@@ -2327,7 +2327,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2327
2327
  if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name))
2328
2328
  return false;
2329
2329
  const manifest = installedManifests.get(name);
2330
- return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined;
2330
+ // A bundle whose own patch is empty loads nothing either way, so
2331
+ // its absence from the list says nothing about a switch (#813).
2332
+ return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
2333
+ && !bundlePatchIsEmpty(join(activeProfileDir, 'node_modules', name));
2331
2334
  });
2332
2335
  for (const name of Object.keys(installed)) {
2333
2336
  const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)), replacedWhileLive.has(name));
@@ -5903,10 +5906,24 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5903
5906
  // inventory (live names and `#<id>`) is the fact
5904
5907
  // "already active this session", the same source
5905
5908
  // verifyActivation reads below.
5906
- const adopted = liveNames().has(name)
5907
- || liveNames().has(`#${name}`)
5908
- || bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
5909
- .some(id => liveNames().has(`#${id}`));
5909
+ const isAdopted = () => {
5910
+ const names = liveNames();
5911
+ return names.has(name)
5912
+ || names.has(`#${name}`)
5913
+ || bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
5914
+ .some(id => names.has(`#${id}`));
5915
+ };
5916
+ let adopted = isAdopted();
5917
+ // The replay lands after pnpm returns, not before (#814):
5918
+ // give a host that replays on its own the time to do it.
5919
+ const waitUntil = Date.now() + (config.hostReplayWaitMs ?? 0);
5920
+ while (!adopted && Date.now() < waitUntil) {
5921
+ await new Promise(resolve => setTimeout(resolve, 250));
5922
+ adopted = isAdopted();
5923
+ }
5924
+ if (adopted && config.hostReplayWaitMs !== undefined) {
5925
+ logEvent('info', 'install', `${name}: the host's own replay activated it; adopted instead of mounting a second copy (#814)`);
5926
+ }
5910
5927
  let live = adopted;
5911
5928
  if (!adopted && !pluginCategories(entry).includes('theme')) {
5912
5929
  // #758: this fallback hot-mount bypasses the host's own
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed' | 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -82,6 +82,15 @@ export interface MarketConfig {
82
82
  * no longer tells a desktop shell apart from an ordinary `dsh` run.
83
83
  */
84
84
  desktopHost?: boolean;
85
+ /**
86
+ * How long to wait for the host's own replay of a fresh install before
87
+ * hot-mounting it (#814). A desktop shell that watches the profile replays
88
+ * the composition itself, about two seconds after the manifest lands; one
89
+ * that does not publish `pluginActivation` gives the market no way to ask,
90
+ * and mounting first made the host's replay fail as a duplicate. Unset
91
+ * means no host replay is expected.
92
+ */
93
+ hostReplayWaitMs?: number;
85
94
  /** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
86
95
  allowRestart?: boolean;
87
96
  /** Which release channel the market offers ITSELF from; other plugins never follow it. */
@@ -41,7 +41,23 @@ export interface ActivationResult {
41
41
  * chose (#634). Only ever set on `inert`.
42
42
  */
43
43
  dependencyOf?: string;
44
+ /**
45
+ * Set on `inert` when the package's own bundle patch loads nothing but the
46
+ * user's own patch names it somewhere — an Agent preset's plugin row, say
47
+ * (#813). The market cannot see those trees, so it neither promises a
48
+ * restart nor calls the plugin off; it says where the plugin comes from.
49
+ */
50
+ loadedByProfile?: true;
44
51
  }
52
+ /**
53
+ * Whether every bundle patch the package declares parses to an empty list.
54
+ *
55
+ * Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
56
+ * or taking it out changes nothing, so neither "restart to apply" nor the
57
+ * #696 "removed from bundles means off" inference applies to it (#813). A
58
+ * patch that is missing or unreadable is NOT empty — other verdicts own it.
59
+ */
60
+ export declare function bundlePatchIsEmpty(packageDir: string): boolean;
45
61
  /**
46
62
  * What the running host is, for the #757 boot-gate prediction.
47
63
  *
package/lib/verify.js CHANGED
@@ -32,9 +32,45 @@ import { join } from 'node:path';
32
32
  import { listHotMounts, parseSimplePatch } from './hot.js';
33
33
  import { userPatchPackageReferences } from './patch.js';
34
34
  import { nameMatchesPackage } from './entry-identity.js';
35
- import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
36
- import { compareSemver, satisfiesRange } from './check.js';
35
+ import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
36
+ import { compareSemver, parsePatchFile, satisfiesRange } from './check.js';
37
37
  import { dshHostInfo } from './dsh-install.js';
38
+ /**
39
+ * Whether every bundle patch the package declares parses to an empty list.
40
+ *
41
+ * Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
42
+ * or taking it out changes nothing, so neither "restart to apply" nor the
43
+ * #696 "removed from bundles means off" inference applies to it (#813). A
44
+ * patch that is missing or unreadable is NOT empty — other verdicts own it.
45
+ */
46
+ export function bundlePatchIsEmpty(packageDir) {
47
+ const files = declaredBundlePatchFiles(packageDir);
48
+ return files.length > 0 && files.every(file => parsePatchFile(file)?.length === 0);
49
+ }
50
+ /**
51
+ * Whether the profile's own `cordis.patch.yml` names this package anywhere,
52
+ * at any depth — including rows nested in another plugin's config, such as
53
+ * an Agent preset's `plugins:` list, which never enter the host loader.
54
+ * Evidence of where the plugin comes from, not of whether it is running.
55
+ */
56
+ function profilePatchMentions(activeProfileDir, name) {
57
+ const rows = parsePatchFile(join(activeProfileDir, 'cordis.patch.yml'));
58
+ if (rows === null)
59
+ return false;
60
+ const seen = new Set();
61
+ const visit = (value) => {
62
+ if (value === null || typeof value !== 'object' || seen.has(value))
63
+ return false;
64
+ seen.add(value);
65
+ if (Array.isArray(value))
66
+ return value.some(visit);
67
+ const row = value;
68
+ if (nameMatchesPackage(row.name, name))
69
+ return true;
70
+ return Object.values(row).some(visit);
71
+ };
72
+ return rows.some(visit);
73
+ }
38
74
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
39
75
  function readBundles(profile, explicitDir) {
40
76
  try {
@@ -329,6 +365,26 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
329
365
  hot: true,
330
366
  };
331
367
  }
368
+ // An empty bundle patch (#813): the bundle row brings nothing in, so a
369
+ // restart cannot change the verdict and "restart to apply" would stand
370
+ // forever. Say where the plugin comes from instead, when the profile's own
371
+ // patch names it; otherwise nothing loads it, and that is the truth.
372
+ if (dsh.client === undefined && bundlePatchIsEmpty(dir)) {
373
+ return profilePatchMentions(activeProfileDir, name)
374
+ ? {
375
+ state: 'inert',
376
+ loadedByProfile: true,
377
+ reasons: ['它自带的 bundle 补丁是空的,由你的配置(例如 Agent 预设)加载;市场看不到它是否正在运行 / its own bundle patch is empty, so your own setup (an Agent preset, for example) loads it; the market cannot see whether it is running'],
378
+ bundle: inBundles,
379
+ hot: false,
380
+ }
381
+ : {
382
+ state: 'inert',
383
+ reasons: ['它自带的 bundle 补丁是空的,你的配置里也没有引用它,所以不会加载 / its own bundle patch is empty and your setup does not reference it, so nothing loads it'],
384
+ bundle: inBundles,
385
+ hot: false,
386
+ };
387
+ }
332
388
  if (inBundles) {
333
389
  // #757: "restart to apply" is only honest when the next boot would
334
390
  // actually load the bundle. The host skips bundles whose dsh peer
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.14",
4
+ "version": "1.66.15",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -137,7 +137,11 @@ function activationMeta(
137
137
  state: ActivationState,
138
138
  t: Translate,
139
139
  dependencyOf?: string,
140
+ loadedByProfile?: true,
140
141
  ): { label: string; dot: 'done' | 'warning' | 'error' } {
142
+ // Loaded by the user's own setup, out of the market's sight (#813): a fact
143
+ // about where it comes from, not a warning.
144
+ if (state === 'inert' && loadedByProfile === true) return { label: t('stateLoadedByProfile'), dot: 'done' }
141
145
  // A library another plugin pulled in is not a plugin that failed to start,
142
146
  // so it gets its own label and no warning dot (#634).
143
147
  if (state === 'inert' && dependencyOf !== undefined) {
@@ -3006,11 +3010,10 @@ export function MarketSection(props: MarketSectionProps) {
3006
3010
  return
3007
3011
  }
3008
3012
  if (status === 200 && body.ok) {
3009
- sessionStorage.setItem('dshm-tab', 'installed')
3010
3013
  if (body.activation && typeof body.activation === 'object') {
3011
3014
  setActivations(prev => ({ ...prev, ...body.activation }))
3012
3015
  const warns = Object.entries(body.activation as Record<string, ActivationInfo>)
3013
- .filter(([, info]) => info.state !== 'live' && info.state !== 'missing')
3016
+ .filter(([, info]) => info.state !== 'live' && info.state !== 'missing' && info.loadedByProfile !== true)
3014
3017
  .map(([name, info]) => ({ name, info }))
3015
3018
  setActivationWarnings(warns)
3016
3019
  }
@@ -5726,7 +5729,7 @@ export function MarketSection(props: MarketSectionProps) {
5726
5729
  <span className={css.grow}>
5727
5730
  {activationWarnings.map(({ name, info }) => (
5728
5731
  <div key={name}>
5729
- <b>{name}</b> — {activationMeta(info.state, t, info.dependencyOf).label}
5732
+ <b>{name}</b> — {activationMeta(info.state, t, info.dependencyOf, info.loadedByProfile).label}
5730
5733
  {info.reasons.length > 0 && <span className={css.spec}>({localizeBilingualList(info.reasons, lang)})</span>}
5731
5734
  </div>
5732
5735
  ))}
@@ -6478,13 +6481,13 @@ export function MarketSection(props: MarketSectionProps) {
6478
6481
  const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(installed[name]), repoIdentities[name], repoHints[name])
6479
6482
  const off = effectiveDisabledSet.has(name)
6480
6483
  const act = activations[name]
6481
- const meta = !off && act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null
6484
+ const meta = !off && act !== undefined ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null
6482
6485
  const note = notes[name]
6483
6486
  const authored = (entry?.description && (entry.description[lang] || entry.description.en)) || ''
6484
6487
  const shown = note ?? authored
6485
6488
  const stateLabel = off
6486
6489
  ? t('disabledState')
6487
- : act?.state === 'inert' && act.dependencyOf === undefined
6490
+ : act?.state === 'inert' && act.dependencyOf === undefined && act.loadedByProfile !== true
6488
6491
  ? t('groupStateInert')
6489
6492
  : act?.state === 'restart'
6490
6493
  ? t('groupStateRestart')
@@ -6585,7 +6588,7 @@ export function MarketSection(props: MarketSectionProps) {
6585
6588
  const generation = status?.kind === 'generation' || isGenerationSpec(String(spec))
6586
6589
  const localDev = isLocalDev(String(spec), status)
6587
6590
  const act = activations[name]
6588
- const meta = act !== undefined ? activationMeta(act.state, t, act.dependencyOf) : null
6591
+ const meta = act !== undefined ? activationMeta(act.state, t, act.dependencyOf, act.loadedByProfile) : null
6589
6592
  const version = status && status.version ? 'v' + status.version : ''
6590
6593
  const specText = String(spec)
6591
6594
  // A plain range beside the resolved version says the
@@ -451,6 +451,7 @@ export const zh = {
451
451
  stateIncompatible: '已安装,与当前 dsh 不兼容',
452
452
  stateInert: '已安装,未生效',
453
453
  stateDependencyLibrary: '{0} 的依赖库',
454
+ stateLoadedByProfile: '由你的配置加载',
454
455
  stateBroken: '已安装,校验未通过',
455
456
  stateDisabled: '已停用',
456
457
  phaseResolving: '准备依赖',
@@ -1161,6 +1162,7 @@ export const en: Record<MarketKey, string> = {
1161
1162
  stateIncompatible: 'Installed — incompatible with this dsh',
1162
1163
  stateInert: 'Installed, not active',
1163
1164
  stateDependencyLibrary: 'Library for {0}',
1165
+ stateLoadedByProfile: 'Loaded by your setup',
1164
1166
  stateBroken: 'Installed, verification failed',
1165
1167
  stateDisabled: 'Disabled',
1166
1168
  phaseResolving: 'Preparing dependencies',
@@ -302,6 +302,8 @@ export interface ActivationInfo {
302
302
  hot: boolean
303
303
  /** Set when this package is a library another installed plugin pulled in (#634). */
304
304
  dependencyOf?: string
305
+ /** Loaded by the user's own setup, which the market cannot see (#813). */
306
+ loadedByProfile?: true
305
307
  }
306
308
 
307
309
  /** The /dsh-market/installed payload (fields the market UI consumes). */
@@ -489,8 +491,13 @@ function cachedPluginSearchText(plugin: RegistryPlugin, value: string): string {
489
491
  }
490
492
 
491
493
  /**
492
- * Relevance within one field. Exact and prefix matches beat phrase matches;
493
- * for a multi-word query every word must occur in the same field.
494
+ * Relevance within one field. Exact matches beat phrase matches, which beat
495
+ * words scattered through the field; for a multi-word query every word must
496
+ * occur in the same field. A prefix match is NOT ranked above a phrase match
497
+ * anywhere else in the field: most catalog names start with `dsh-`, so that
498
+ * distinction mostly punished the naming convention and overrode the sort
499
+ * the user picked (#815: `computer-use-vision` with 355 downloads above
500
+ * `dsh-computer-use` with 3.6k, under "downloads").
494
501
  */
495
502
  function fieldRelevance(
496
503
  plugin: RegistryPlugin,
@@ -503,7 +510,6 @@ function fieldRelevance(
503
510
  const text = cachedPluginSearchText(plugin, value)
504
511
  if (text === '' || !tokens.every(token => text.includes(token))) return 0
505
512
  if (text === query) return weight + 300
506
- if (text.startsWith(query)) return weight + 250
507
513
  if (text.includes(query)) return weight + 200
508
514
  return weight + 150
509
515
  }
package/src/index.ts CHANGED
@@ -348,6 +348,9 @@ export function apply(ctx: Context, config?: Config): void {
348
348
  // capability bits report that, and an explicit profile directory no
349
349
  // longer implies it (#639).
350
350
  desktopHost: true,
351
+ // A shell that cannot be asked to replay still replays on its own,
352
+ // shortly after the install lands (#814): wait for it and adopt.
353
+ ...(desktopProfiles.pluginActivation === undefined ? { hostReplayWaitMs: 8000 } : {}),
351
354
  // Relaunching a raw Electron process would bypass Desktop's launcher
352
355
  // lifecycle. The shell remains responsible for restart in this mode.
353
356
  allowRestart: false,
@@ -50,6 +50,7 @@ export interface PnpmFailure {
50
50
  | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked'
51
51
  | 'profile-file-locked'
52
52
  | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
53
+ | 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled'
53
54
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
54
55
  message: string
55
56
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -185,6 +186,26 @@ function integrityViolators(diagnostic: string): string[] {
185
186
  * @returns the classified failure, or null when unrecognized (raw output is then shown as-is).
186
187
  */
187
188
  export function classifyPnpmFailure(output: string, exitCode?: number | null): PnpmFailure | null {
189
+ // #817: the host's silence watchdog (@deepseek-ai/dsh-plugin-manager,
190
+ // `idleTimeoutMs`, 10 minutes by default) killed pnpm. Checked first: it is
191
+ // the last thing that happened, so whatever pnpm printed before is not the
192
+ // cause. The usual culprit is a dependency's install script downloading a
193
+ // binary from a host this network cannot reach — the reporter's cloudflared
194
+ // postinstall fetching from GitHub Releases — so the last lifecycle line
195
+ // pnpm printed names the package that went quiet.
196
+ const stalled = /printed nothing for (\d+)ms and was terminated/.exec(output)
197
+ if (stalled !== null) {
198
+ const minutes = Math.max(1, Math.round(Number(stalled[1]) / 60000))
199
+ const scripts = [...output.matchAll(/node_modules[\\/]((?:@[^\\/\s]+[\\/])?[^\\/\s]+) (?:pre|post)?install[:$]/g)]
200
+ const culprit = scripts.at(-1)?.[1]?.replace('\\', '/')
201
+ return {
202
+ code: 'pnpm-stalled',
203
+ recoverable: false,
204
+ message: culprit !== undefined
205
+ ? `依赖 ${culprit} 的安装脚本 ${minutes} 分钟没有任何输出,被终止了,什么都没装上。这类脚本通常在从网上下载文件,多半是当前网络连不上下载地址;插件本身没有问题。换个网络或开启代理后重试;仍然不行就联系插件作者。 / the install script of the dependency ${culprit} printed nothing for ${minutes} minutes and was stopped, so nothing was installed. Scripts like this usually download a file, and this network most likely cannot reach where it comes from; the plugin itself is not at fault. Try again on another network or through a proxy, and contact the plugin's author if it still fails.`
206
+ : `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
207
+ }
208
+ }
188
209
  if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
189
210
  || output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
190
211
  return {
@@ -494,6 +515,59 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
494
515
  : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
495
516
  }
496
517
  }
518
+ // #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
519
+ // two shapes that pnpm reports with two codes and two different repairs, so
520
+ // they are classified apart rather than merged into one answer.
521
+ //
522
+ // `lockfile-outdated` is tested first, because its anchor is a code and
523
+ // nothing else. The sentence pnpm prints here also arrives WRAPPED across
524
+ // lines in the human reporter ("… is not up" / "to date with
525
+ // package.json."), so keying on that sentence instead of the code would miss
526
+ // the form users actually see.
527
+ //
528
+ // The two failures do not happen in a fixed order, so nothing here — and
529
+ // nothing in either message — claims which one a user meets first.
530
+ //
531
+ // `recoverable: false` on both: repeating the same command cannot help, the
532
+ // lockfile has to change first.
533
+ if (output.includes('ERR_PNPM_OUTDATED_LOCKFILE')) {
534
+ return {
535
+ code: 'lockfile-outdated',
536
+ recoverable: false,
537
+ message: 'profile 的 pnpm-lock.yaml 与 package.json 对不上:锁文件里记的 specifier 和 manifest 里声明的不是同一个,pnpm 因此在 frozen-lockfile 这一步直接拒绝,什么都没装。先备份一份 pnpm-lock.yaml,再在 profile 目录里重新生成锁文件(pnpm 最后一行会给出它自己那一版的命令;pnpm install --lockfile-only 也让锁文件与 package.json 对齐)后重试。这与「锁文件里缺条目」是两个不同的报错、对应两种不同的修法,两条命令不要互相套用(#808) / this profile\'s pnpm-lock.yaml does not match package.json: the specifier recorded in the lockfile is not the one the manifest declares, so pnpm refuses at the frozen-lockfile check and installs nothing. Back up pnpm-lock.yaml first, then regenerate the lockfile in the profile directory (pnpm\'s last line names the command for its own version; pnpm install --lockfile-only also realigns the lockfile with package.json) and retry. This is a different error with a different repair from a lockfile that is missing an entry — do not apply one repair to the other',
538
+ }
539
+ }
540
+ // `lockfile-broken` is the lockfile contradicting ITSELF: an importer names
541
+ // a dependency that has no entry under `packages:`. pnpm 11 and 12 word it
542
+ // identically and both carry the code, so the rule keys on the code and on
543
+ // pnpm's own phrase — and deliberately NOT on pnpm's closing `help:` line,
544
+ // which drifts: 11.24.0 offers `pnpm install --no-frozen-lockfile` for this
545
+ // failure while 12.8.1 offers `pnpm install --fix-lockfile` (and for the
546
+ // outdated failure above it offers a third command again, `--lockfile-only`).
547
+ // Measured 2026-10-08 against 11.24.0 and 12.8.1.
548
+ //
549
+ // Both shapes reach here whichever stream pnpm used. Market commands run with
550
+ // `--reporter=ndjson`, and in that same measurement 11.24.0 wrote the error
551
+ // record to stdout as one JSON line while 12.8.1 wrote nothing to stdout at
552
+ // all (0 bytes) and put the error block on stderr as plain text.
553
+ // withDecodedPnpmDiagnostics lifts the message out of the first form, and
554
+ // both forms then carry these two strings verbatim.
555
+ //
556
+ // The missing entry is named in the message but deliberately NOT put in
557
+ // `pkg`: callers read `pkg` as "the package this failure is about" and act on
558
+ // it (the #289 peer retry, the #65 ghost-entry advice), and neither applies —
559
+ // the repair is in the lockfile, not in the manifest entry.
560
+ if (output.includes('ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY') || /Broken lockfile/.test(output)) {
561
+ const broken = /no entry for '?([^'\s]+@[^'\s]+)'? in pnpm-lock\.yaml/i
562
+ .exec(withDecodedPnpmDiagnostics(output))?.[1]
563
+ const zh = broken === undefined ? '' : `(${broken})`
564
+ const en = broken === undefined ? '' : ` (${broken})`
565
+ return {
566
+ code: 'lockfile-broken',
567
+ recoverable: false,
568
+ message: `profile 的 pnpm-lock.yaml 自相矛盾:有个依赖在 importers 里记着,packages 里却没有它的条目${zh},pnpm 因此在真正写 node_modules 的那一步拒绝整个命令。先备份一份 pnpm-lock.yaml;在 importers 下删掉这个依赖的那条记录(它的名字、specifier、version 三行),再在 profile 目录里执行 pnpm install --no-frozen-lockfile --lockfile-only,然后重试。不要用 pnpm 提示的 --fix-lockfile:在 12.x 上它修不好这种状态(11.x 上能修,所以那条提示只在 12.x 的 help 里出现,照它走会把 12.x 用户送到一条一定失败的命令上)。不要删掉整个 pnpm-lock.yaml,那会把其余插件的版本一起重新解析(#808) / this profile\'s pnpm-lock.yaml contradicts itself: a dependency is recorded in importers but has no entry under packages${en}, so pnpm refuses the whole command at the step that actually writes node_modules. Back up pnpm-lock.yaml first; remove that dependency\'s record from importers (its name, specifier and version lines); then run pnpm install --no-frozen-lockfile --lockfile-only in the profile directory and retry. Do not use the --fix-lockfile that pnpm itself suggests: on 12.x it does not repair this state (it does on 11.x, which is why that suggestion only appears in 12.x\'s help, and following it sends a 12.x user to a command that always fails). Do not delete the whole pnpm-lock.yaml either: that re-resolves every other plugin\'s version too (#808)`,
569
+ }
570
+ }
497
571
  // #786 follow-up: pnpm could not replace one of the profile's own files —
498
572
  // `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
499
573
  // `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
package/src/profile.ts CHANGED
@@ -1405,23 +1405,43 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
1405
1405
  const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml')
1406
1406
  let yaml: string
1407
1407
  try { yaml = readFileSync(file, 'utf8') } catch { return [] }
1408
- // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines.
1409
- const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m
1408
+ // Block form only: `minimumReleaseAgeExclude:` then `- <entry>` lines, with
1409
+ // comment lines allowed among them. Users annotate why a version is exempt,
1410
+ // and a comment line used to end the block — or veto the whole repair — so
1411
+ // the very profiles someone had tended by hand stayed broken (#816).
1412
+ const blockRe = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:(?:[ \t]+-[^\r\n]*|[ \t]*#[^\r\n]*)\r?\n?)*)/m
1410
1413
  const block = blockRe.exec(yaml)
1411
1414
  if (block === null) return []
1412
1415
  const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
1413
- const indentMatch = /^([ \t]+)-/.exec(block[1])
1416
+ const indentMatch = /^([ \t]+)-/m.exec(block[1])
1414
1417
  const indent = indentMatch === null ? ' ' : indentMatch[1]
1415
- const entries: { rule: ReleaseAgeExcludeRule; quoted: boolean; line: string }[] = []
1418
+ /**
1419
+ * The block in its original order. Comment lines are kept verbatim; an
1420
+ * entry keeps its trailing ` # …` comment, which YAML only recognises after
1421
+ * whitespace. A `#` anywhere else in an entry is not something this can
1422
+ * read exactly, so the file is left alone.
1423
+ */
1424
+ type Item =
1425
+ | { kind: 'comment'; line: string }
1426
+ | { kind: 'entry'; rule: ReleaseAgeExcludeRule; quoted: boolean; bare: string; comment: string | null }
1427
+ const items: Item[] = []
1416
1428
  for (const line of block[1].split(/\r?\n/)) {
1417
1429
  if (line.trim() === '') continue
1418
- const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line)
1419
- // A `#` on the line is a comment this cannot re-emit without losing it.
1420
- if (m === null || m[1].includes('#')) return []
1421
- const rule = splitReleaseAgeExclude(m[1])
1430
+ if (/^[ \t]*#/.test(line)) {
1431
+ items.push({ kind: 'comment', line })
1432
+ continue
1433
+ }
1434
+ const m = /^([ \t]+-[ \t]*)(.*?)[ \t]*$/.exec(line)
1435
+ if (m === null) return []
1436
+ const commentAt = /[ \t]+#/.exec(m[2])
1437
+ const value = commentAt === null ? m[2] : m[2].slice(0, commentAt.index)
1438
+ const comment = commentAt === null ? null : m[2].slice(commentAt.index).trim()
1439
+ if (value.includes('#')) return []
1440
+ const rule = splitReleaseAgeExclude(value)
1422
1441
  if (rule === null) return []
1423
- entries.push({ rule, quoted: /^['"]/.test(m[1]), line })
1442
+ items.push({ kind: 'entry', rule, quoted: /^['"]/.test(value), bare: `${m[1]}${value}`, comment })
1424
1443
  }
1444
+ const entries = items.flatMap(item => item.kind === 'entry' ? [{ ...item, line: item.bare }] : [])
1425
1445
  const byName = new Map<string, {
1426
1446
  selectors: Set<string>
1427
1447
  originals: string[]
@@ -1470,7 +1490,16 @@ export function mergeDuplicateReleaseAgeExcludes(profile: string, explicitDir?:
1470
1490
  return group.count > 1 || group.lines[0] !== produced(name)
1471
1491
  })
1472
1492
  if (rewritten.length === 0) return []
1473
- const lines = order.map(produced)
1493
+ // Each package's one line goes where its FIRST rule was. A later duplicate
1494
+ // is dropped, but a comment it carried stays, as a comment line in its place.
1495
+ const written = new Set<string>()
1496
+ const lines = items.flatMap(item => {
1497
+ if (item.kind === 'comment') return [item.line]
1498
+ const suffix = item.comment === null ? '' : ` ${item.comment}`
1499
+ if (written.has(item.rule.name)) return item.comment === null ? [] : [`${indent}${item.comment}`]
1500
+ written.add(item.rule.name)
1501
+ return [`${produced(item.rule.name)}${suffix}`]
1502
+ })
1474
1503
  const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`
1475
1504
  // A function replacement: `$&` and friends in a string replacement would
1476
1505
  // be read as capture references.
package/src/routes.ts CHANGED
@@ -55,7 +55,7 @@ import { createThemeManager, type LoaderEntry } from './themes.ts'
55
55
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
56
56
  import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
57
57
  import type { RecoveryPlugin } from './recovery.ts'
58
- import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
58
+ import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
59
59
  import {
60
60
  carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags,
61
61
  readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences,
@@ -145,6 +145,15 @@ export interface MarketConfig {
145
145
  * no longer tells a desktop shell apart from an ordinary `dsh` run.
146
146
  */
147
147
  desktopHost?: boolean
148
+ /**
149
+ * How long to wait for the host's own replay of a fresh install before
150
+ * hot-mounting it (#814). A desktop shell that watches the profile replays
151
+ * the composition itself, about two seconds after the manifest lands; one
152
+ * that does not publish `pluginActivation` gives the market no way to ask,
153
+ * and mounting first made the host's replay fail as a duplicate. Unset
154
+ * means no host replay is expected.
155
+ */
156
+ hostReplayWaitMs?: number
148
157
  /** Detached self-restart is unsafe under systemd/launchd/pm2; operators can disable it (#14). */
149
158
  allowRestart?: boolean
150
159
  /** Which release channel the market offers ITSELF from; other plugins never follow it. */
@@ -2521,7 +2530,10 @@ export function mountMarketRoutes(
2521
2530
  const unbundled = Object.keys(installed).filter(name => {
2522
2531
  if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name)) return false
2523
2532
  const manifest = installedManifests.get(name) as { dsh?: { bundle?: unknown } } | null | undefined
2533
+ // A bundle whose own patch is empty loads nothing either way, so
2534
+ // its absence from the list says nothing about a switch (#813).
2524
2535
  return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
2536
+ && !bundlePatchIsEmpty(join(activeProfileDir, 'node_modules', name))
2525
2537
  })
2526
2538
  for (const name of Object.keys(installed)) {
2527
2539
  const result = activationAfterReplace(
@@ -6114,10 +6126,24 @@ sendJson(response, 200, { updates })
6114
6126
  // inventory (live names and `#<id>`) is the fact
6115
6127
  // "already active this session", the same source
6116
6128
  // verifyActivation reads below.
6117
- const adopted = liveNames().has(name)
6118
- || liveNames().has(`#${name}`)
6119
- || bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
6120
- .some(id => liveNames().has(`#${id}`))
6129
+ const isAdopted = (): boolean => {
6130
+ const names = liveNames()
6131
+ return names.has(name)
6132
+ || names.has(`#${name}`)
6133
+ || bundlePatchInsertedIds(join(activeProfileDir, 'node_modules', name))
6134
+ .some(id => names.has(`#${id}`))
6135
+ }
6136
+ let adopted = isAdopted()
6137
+ // The replay lands after pnpm returns, not before (#814):
6138
+ // give a host that replays on its own the time to do it.
6139
+ const waitUntil = Date.now() + (config.hostReplayWaitMs ?? 0)
6140
+ while (!adopted && Date.now() < waitUntil) {
6141
+ await new Promise(resolve => setTimeout(resolve, 250))
6142
+ adopted = isAdopted()
6143
+ }
6144
+ if (adopted && config.hostReplayWaitMs !== undefined) {
6145
+ logEvent('info', 'install', `${name}: the host's own replay activated it; adopted instead of mounting a second copy (#814)`)
6146
+ }
6121
6147
  let live = adopted
6122
6148
  if (!adopted && !pluginCategories(entry).includes('theme')) {
6123
6149
  // #758: this fallback hot-mount bypasses the host's own
package/src/verify.ts CHANGED
@@ -33,8 +33,8 @@ import { join } from 'node:path'
33
33
  import { listHotMounts, parseSimplePatch } from './hot.ts'
34
34
  import { userPatchPackageReferences } from './patch.ts'
35
35
  import { nameMatchesPackage } from './entry-identity.ts'
36
- import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
37
- import { compareSemver, satisfiesRange } from './check.ts'
36
+ import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
37
+ import { compareSemver, parsePatchFile, satisfiesRange } from './check.ts'
38
38
  import { dshHostInfo } from './dsh-install.ts'
39
39
 
40
40
  export type ActivationState = 'live' | 'restart' | 'incompatible' | 'inert' | 'broken' | 'missing' | 'disabled'
@@ -53,6 +53,47 @@ export interface ActivationResult {
53
53
  * chose (#634). Only ever set on `inert`.
54
54
  */
55
55
  dependencyOf?: string
56
+ /**
57
+ * Set on `inert` when the package's own bundle patch loads nothing but the
58
+ * user's own patch names it somewhere — an Agent preset's plugin row, say
59
+ * (#813). The market cannot see those trees, so it neither promises a
60
+ * restart nor calls the plugin off; it says where the plugin comes from.
61
+ */
62
+ loadedByProfile?: true
63
+ }
64
+
65
+ /**
66
+ * Whether every bundle patch the package declares parses to an empty list.
67
+ *
68
+ * Such a bundle row loads nothing at boot: listing it in `dsh.profile.bundles`
69
+ * or taking it out changes nothing, so neither "restart to apply" nor the
70
+ * #696 "removed from bundles means off" inference applies to it (#813). A
71
+ * patch that is missing or unreadable is NOT empty — other verdicts own it.
72
+ */
73
+ export function bundlePatchIsEmpty(packageDir: string): boolean {
74
+ const files = declaredBundlePatchFiles(packageDir)
75
+ return files.length > 0 && files.every(file => parsePatchFile(file)?.length === 0)
76
+ }
77
+
78
+ /**
79
+ * Whether the profile's own `cordis.patch.yml` names this package anywhere,
80
+ * at any depth — including rows nested in another plugin's config, such as
81
+ * an Agent preset's `plugins:` list, which never enter the host loader.
82
+ * Evidence of where the plugin comes from, not of whether it is running.
83
+ */
84
+ function profilePatchMentions(activeProfileDir: string, name: string): boolean {
85
+ const rows = parsePatchFile(join(activeProfileDir, 'cordis.patch.yml'))
86
+ if (rows === null) return false
87
+ const seen = new Set<unknown>()
88
+ const visit = (value: unknown): boolean => {
89
+ if (value === null || typeof value !== 'object' || seen.has(value)) return false
90
+ seen.add(value)
91
+ if (Array.isArray(value)) return value.some(visit)
92
+ const row = value as Record<string, unknown>
93
+ if (nameMatchesPackage(row.name, name)) return true
94
+ return Object.values(row).some(visit)
95
+ }
96
+ return rows.some(visit)
56
97
  }
57
98
 
58
99
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
@@ -399,6 +440,27 @@ export function verifyActivation(
399
440
  }
400
441
  }
401
442
 
443
+ // An empty bundle patch (#813): the bundle row brings nothing in, so a
444
+ // restart cannot change the verdict and "restart to apply" would stand
445
+ // forever. Say where the plugin comes from instead, when the profile's own
446
+ // patch names it; otherwise nothing loads it, and that is the truth.
447
+ if (dsh.client === undefined && bundlePatchIsEmpty(dir)) {
448
+ return profilePatchMentions(activeProfileDir, name)
449
+ ? {
450
+ state: 'inert',
451
+ loadedByProfile: true,
452
+ reasons: ['它自带的 bundle 补丁是空的,由你的配置(例如 Agent 预设)加载;市场看不到它是否正在运行 / its own bundle patch is empty, so your own setup (an Agent preset, for example) loads it; the market cannot see whether it is running'],
453
+ bundle: inBundles,
454
+ hot: false,
455
+ }
456
+ : {
457
+ state: 'inert',
458
+ reasons: ['它自带的 bundle 补丁是空的,你的配置里也没有引用它,所以不会加载 / its own bundle patch is empty and your setup does not reference it, so nothing loads it'],
459
+ bundle: inBundles,
460
+ hot: false,
461
+ }
462
+ }
463
+
402
464
  if (inBundles) {
403
465
  // #757: "restart to apply" is only honest when the next boot would
404
466
  // actually load the bundle. The host skips bundles whose dsh peer