dsh-plugin-shop 0.8.3 → 0.8.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,17 +1,18 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
3
  import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
4
- import { lt, minVersion, satisfies, valid, validRange } from "semver";
4
+ import { lt, minVersion, parse, satisfies, valid, validRange } from "semver";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
- import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
8
+ import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { z } from "zod";
11
11
  import { gunzipSync } from "node:zlib";
12
12
  import { spawn, spawnSync } from "node:child_process";
13
13
  import { StringDecoder } from "node:string_decoder";
14
- import { DEFAULT_SCHEMA, JSON_SCHEMA, Type, dump, load } from "js-yaml";
14
+ import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
15
+ import { isMap, isSeq, parseDocument } from "yaml";
15
16
  //#region src/own-version.ts
16
17
  /** The shop's own published version, read from the package.json that ships
17
18
  * next to this package — the RUNNING version, not the manifest's range
@@ -22,12 +23,34 @@ import { DEFAULT_SCHEMA, JSON_SCHEMA, Type, dump, load } from "js-yaml";
22
23
  function ownManifest() {
23
24
  return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
24
25
  }
25
- function ownVersion() {
26
- return ownManifest().version;
26
+ /**
27
+ * Read a version once and keep it: the answer to "which code is this".
28
+ *
29
+ * The file cannot answer that question later. A profile installs hoisted, so
30
+ * a self-update rewrites package.json in the directory the RUNNING code was
31
+ * loaded from, and every read after it names the version that will run after
32
+ * the next restart — which the version row then printed as the one running
33
+ * (design §7.3, 2026-09-27 amendment).
34
+ *
35
+ * A first read that throws is not kept: the module import must not fail over
36
+ * a version string, so the answer falls back to reading on demand, which is
37
+ * what the row always did.
38
+ */
39
+ function versionAtLoad(read) {
40
+ let loaded;
41
+ try {
42
+ loaded = read();
43
+ } catch {
44
+ loaded = null;
45
+ }
46
+ return () => loaded ?? read();
27
47
  }
48
+ /** The version of the code in this process, read when the module was first
49
+ * evaluated — at boot, before any self-update could rewrite the file. */
50
+ const runningVersion = versionAtLoad(() => ownManifest().version);
28
51
  /**
29
52
  * The peer ranges this build declares — the input to the load-time harness
30
- * self-check. Read from the same shipped manifest as `ownVersion`, so the
53
+ * self-check. Read from the same shipped manifest as `runningVersion`, so the
31
54
  * ranges checked are the ones this build was actually published with, never
32
55
  * a second copy that can drift from them.
33
56
  *
@@ -609,6 +632,11 @@ const entrySchema = z.object({
609
632
  }).optional(),
610
633
  publisher: z.string().optional(),
611
634
  peers: z.array(z.string()).optional(),
635
+ compatibility: z.object({
636
+ dsh: z.string().optional(),
637
+ profiles: z.array(z.string()).optional()
638
+ }).optional(),
639
+ dshPeers: z.record(z.string(), z.string()).optional(),
612
640
  unpackedSize: z.number().int().nonnegative().optional(),
613
641
  installSize: z.number().int().nonnegative().optional()
614
642
  }).superRefine((entry, ctx) => {
@@ -1034,6 +1062,248 @@ function validateInstall(snapshot, args, installedSpec) {
1034
1062
  };
1035
1063
  }
1036
1064
  //#endregion
1065
+ //#region src/host/compatibility.ts
1066
+ /** Harness compatibility as the AUTHOR states it: which `dsh.compatibility`
1067
+ * declarations the running installation does not meet (design
1068
+ * 2026-09-01-harness-compatibility §8.2, as amended §9.9). The presence check
1069
+ * in `peers.ts` infers from what a plugin imports; this reads what its author
1070
+ * wrote down. And, from dsh 0.1.7 on, what dsh ITSELF refuses: the harness
1071
+ * peers its installer rejects an install on (`peerVerdictsOf`, design
1072
+ * 2026-09-26-dsh-017-readiness, B1).
1073
+ *
1074
+ * Pure: the running version, the running profile, the harness's own
1075
+ * template table and its peer check arrive as arguments, so fixtures drive
1076
+ * every verdict and the reads of the installation stay outside — the running
1077
+ * dsh, its template table and its peer check in `harness.ts`, the running
1078
+ * profile's bundles and exemptions in the gateway. */
1079
+ /**
1080
+ * Normalize the harness's OWN `PROFILE_TEMPLATES` into template name → bundle
1081
+ * names, or an empty record for a shape this build does not know.
1082
+ *
1083
+ * Two shapes exist and both are live: 0.1.5-rc.3 maps a name to
1084
+ * `{ bundles, patchReload }` (five templates), while the version this package
1085
+ * pins as a devDependency maps it to a bare array (two). A name whose value is
1086
+ * neither, or whose bundle list holds a non-string, is dropped rather than
1087
+ * guessed at — an unreadable table means no profile verdict, never a wrong
1088
+ * one. Copied from nowhere: the caller passes the harness's exported object.
1089
+ *
1090
+ * The record has NO PROTOTYPE, empty answer included. It is indexed by the
1091
+ * profile names a catalog entry declares, which are hostile input, and a
1092
+ * record built on `{}` answered `constructor`, `__proto__`, `toString`,
1093
+ * `hasOwnProperty` and `valueOf` from `Object.prototype`: a value that passed
1094
+ * the `undefined` guard and threw out of `compatibilityMap`, so one entry
1095
+ * declaring `profiles: ["constructor"]` rejected every `catalog()` call for
1096
+ * every user. Without a prototype there is also no `__proto__` setter, so a
1097
+ * template exported under that name stays an own key instead of replacing
1098
+ * the record's prototype. `unmetProfile` takes own keys only as well — the
1099
+ * type is exported, and a caller may hand in a plain object.
1100
+ */
1101
+ function profileTemplatesOf(exported) {
1102
+ const templates = Object.create(null);
1103
+ if (exported === null || typeof exported !== "object" || Array.isArray(exported)) return templates;
1104
+ for (const [name, value] of Object.entries(exported)) {
1105
+ const bundles = Array.isArray(value) ? value : value !== null && typeof value === "object" ? value.bundles : void 0;
1106
+ if (!Array.isArray(bundles)) continue;
1107
+ if (!bundles.every((bundle) => typeof bundle === "string")) continue;
1108
+ templates[name] = bundles;
1109
+ }
1110
+ return templates;
1111
+ }
1112
+ /**
1113
+ * Install identity → the half or halves of an entry's declaration this
1114
+ * installation does not meet. A key is present only when something was
1115
+ * declared AND is unmet, so an absent key means "runs here, or we could not
1116
+ * tell" — the rule `incompatibilityMap` follows, and for the same reason: one
1117
+ * false warning teaches a reader to ignore every warning.
1118
+ *
1119
+ * The halves are judged apart, and an unknown on either side silences only its
1120
+ * own half:
1121
+ *
1122
+ * - `dsh` needs a range semver can parse and a running version that is semver,
1123
+ * and is then `satisfies` with `includePrerelease`. That option is
1124
+ * load-bearing: the harness ships nothing but prereleases, and strict semver
1125
+ * refuses a prerelease against any range whose comparators carry none —
1126
+ * `0.1.5-rc.3` fails `>=0.1.0`, and even `*` — so every author who wrote a
1127
+ * plain range would be told they exclude a harness their range includes.
1128
+ *
1129
+ * What the option does NOT do is read a range the way its author probably
1130
+ * meant it. Under it a prerelease is compared like any other version, and a
1131
+ * prerelease sorts BELOW its own release (measured with semver 7.8.5; the
1132
+ * rows are pinned in compatibility.test.ts):
1133
+ * - An upper bound written without `-0` admits the next line's
1134
+ * prereleases: `<0.2.0` and `>=0.1.0 <0.2.0` both admit `0.2.0-rc.1`.
1135
+ * Only a bound that desugars to `<0.2.0-0` refuses it — `^0.1.0`,
1136
+ * `~0.1.0` and `0.1.x` do, as does `<0.2.0-0` written out.
1137
+ * - A floor written without `-0` refuses its own prereleases: `>=0.1.5`,
1138
+ * `^0.1.5` and `0.1.5` all refuse `0.1.5-rc.3`, because `0.1.5` sorts
1139
+ * above it. `>=0.1.5-0` admits every `0.1.5-rc.N`.
1140
+ * So `>=0.1.5-0` and `<0.2.0-0` are the spellings that say what an author
1141
+ * usually means. The comparison stays semver's own and is never rewritten
1142
+ * here: a verdict that coerced prereleases would answer differently from
1143
+ * semver, which is what an author checks a range against. An exact list of
1144
+ * other versions — `@xmanrui/dsh-im`'s declaration — is refused, as it
1145
+ * should be.
1146
+ * - `profile` is met when the running profile's NAME is one of the declared
1147
+ * names, or when it composes every bundle of a declared template.
1148
+ *
1149
+ * The name is trusted for the names dsh ships, because for those it is not
1150
+ * the reader's choice. On 0.1.5-rc.3 both paths that create a missing
1151
+ * profile build one of a shipped name from that name's template
1152
+ * (dsh-app-boot's `loadProfile`, and `dsh plugin`), and dsh refuses a
1153
+ * shipped name as the target of `--from-default-profile`, so a profile
1154
+ * carrying one was built from that template. That is all the name
1155
+ * guarantees: which template the profile was built from, not what it
1156
+ * composes now. Nothing holds the bundle list to the template afterwards:
1157
+ * `dsh plugin`'s reconcile, not app-boot, appends each bundle it installs,
1158
+ * and app-boot's `normalizeShippedProfile` rewrites only a list that is
1159
+ * exactly a retired tuple or the current one. Bundles alone could not
1160
+ * decide there: a profile keeps the bundle list it was created with, so the
1161
+ * first harness release that added a bundle to `web` would badge every
1162
+ * `profiles: ["web"]` plugin (`@xmanrui/dsh-im`) on every existing `web`
1163
+ * profile, with copy saying it supports web and this dsh was launched with
1164
+ * web.
1165
+ *
1166
+ * Any other name is the reader's choice — `dsh --profile rescue
1167
+ * --from-default-profile web` builds a profile called `rescue` from the web
1168
+ * bundles, and dsh records nothing about which template it came from — so a
1169
+ * running name no declaration matches is judged by what the profile IS:
1170
+ * each declared name is read as one of the harness's own templates, met
1171
+ * when every bundle of that template is in the running profile's bundles. A
1172
+ * declared name that is no template — an own key of the table, never an
1173
+ * inherited one — cannot be judged, and a list with such a name and no met
1174
+ * template gives no verdict. The half is unmet only when the running name is
1175
+ * not declared and every declared name is a template this profile does not
1176
+ * compose — see the amendment for the whole rule.
1177
+ *
1178
+ * The residual, knowingly kept: a custom-named profile is still judged by
1179
+ * bundles, so a template that later gained a bundle would badge the custom
1180
+ * profiles created before the change. No PUBLISHED
1181
+ * `@deepseek-ai/dsh-app-boot` had changed a template's bundle list as of
1182
+ * 2026-09-25: all 28 versions, read that day, give every template they
1183
+ * share the same list. One list was retired before the first publish:
1184
+ * every published app-boot, from the first, carries a retired `headless`
1185
+ * tuple (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`,
1186
+ * `@deepseek-ai/dsh-headless`) in `INSTALLATION_OWNED_PROFILE_TUPLES`, and
1187
+ * `normalizeShippedProfile` rewrites a `headless` profile carrying exactly
1188
+ * that list to the current template when it loads.
1189
+ */
1190
+ function compatibilityMap(entries, runtime, templates) {
1191
+ const running = runtime.dshVersion !== null && valid(runtime.dshVersion) !== null ? runtime.dshVersion : null;
1192
+ const out = {};
1193
+ for (const entry of entries) {
1194
+ const declared = entry.compatibility;
1195
+ if (declared === void 0) continue;
1196
+ const verdict = {};
1197
+ const range = declared.dsh;
1198
+ if (range !== void 0 && running !== null && validRange(range) !== null && !satisfies(running, range, { includePrerelease: true })) verdict.dsh = {
1199
+ range,
1200
+ running
1201
+ };
1202
+ const unmet = unmetProfile(declared.profiles, runtime.profile, templates);
1203
+ if (unmet !== null) verdict.profile = {
1204
+ declared: [...unmet],
1205
+ running: runtime.profile.name
1206
+ };
1207
+ if (verdict.dsh !== void 0 || verdict.profile !== void 0) out[identityKey(entry)] = verdict;
1208
+ }
1209
+ return out;
1210
+ }
1211
+ /**
1212
+ * The declared profile list to report, or null when nothing is owed: met,
1213
+ * unjudgeable, or nothing declared. See `compatibilityMap` for the rule, why
1214
+ * a matching name is enough, and why bundles decide for any other name.
1215
+ */
1216
+ function unmetProfile(declared, running, templates) {
1217
+ if (declared === void 0 || declared.length === 0) return null;
1218
+ if (declared.includes(running.name)) return null;
1219
+ if (running.bundles === null) return null;
1220
+ const composed = new Set(running.bundles);
1221
+ let unjudged = false;
1222
+ for (const name of declared) {
1223
+ const bundles = Object.hasOwn(templates, name) ? templates[name] : void 0;
1224
+ if (bundles === void 0) {
1225
+ unjudged = true;
1226
+ continue;
1227
+ }
1228
+ if (bundles.every((bundle) => composed.has(bundle))) return null;
1229
+ }
1230
+ return unjudged ? null : declared;
1231
+ }
1232
+ /**
1233
+ * Install identity → the refusal the running dsh will make, for each entry
1234
+ * whose harness peers it refuses and the profile has not exempted. A key is
1235
+ * present only for a refusal, so an absent key means "dsh installs it, or we
1236
+ * could not tell".
1237
+ *
1238
+ * The rule is dsh's own (`check`, from the running app-boot), handed the
1239
+ * manifest dsh's installer reads before it installs a registry spec — `pnpm
1240
+ * view <spec> name version peerDependencies`, measured in dsh-plugin-manager
1241
+ * 0.1.7-rc.2 — with the catalog's `dshPeers` for the peers. Those are the
1242
+ * only peers the rule reads, verbatim. What this cannot see errs toward
1243
+ * silence, never toward a refusal dsh would not make: a malformed peer range
1244
+ * elsewhere in the manifest, which dsh rejects only after installing, and
1245
+ * the plugins a bundle's patch rows name, which it checks then too. Those
1246
+ * reach the reader as the install's own failure (`installFailureDetail`).
1247
+ *
1248
+ * npm entries only. dsh keys an exemption by the INSTALLED manifest's
1249
+ * `name@version`, and a github entry's catalog version is a commit: its
1250
+ * exemption status cannot be read, and a refusal named here could never be
1251
+ * cleared from the shop. dsh refuses such an install itself, and says how
1252
+ * to exempt it.
1253
+ *
1254
+ * A throw from the check forms no verdict for that entry, as a resolver's
1255
+ * throw does in `incompatibilityMap`.
1256
+ */
1257
+ function peerVerdictsOf(entries, check, exemptions, profile) {
1258
+ const out = {};
1259
+ for (const entry of entries) {
1260
+ if (entry.source !== "npm" || entry.dshPeers === void 0) continue;
1261
+ let issue;
1262
+ try {
1263
+ issue = check.evaluate({
1264
+ name: entry.name,
1265
+ version: entry.version,
1266
+ peerDependencies: entry.dshPeers
1267
+ }, exemptions);
1268
+ } catch {
1269
+ continue;
1270
+ }
1271
+ if (issue === void 0 || issue.exempted) continue;
1272
+ out[identityKey(entry)] = {
1273
+ refused: { ...issue.peers },
1274
+ running: issue.runtimeVersion,
1275
+ allowCommand: allowVersionCommand(profile, issue)
1276
+ };
1277
+ }
1278
+ return out;
1279
+ }
1280
+ /** npm's package-name grammar as dsh's exemption records accept it, verbatim
1281
+ * from app-boot 0.1.7-rc.2 (`PACKAGE_NAME` in profile-compatibility). */
1282
+ const EXEMPTION_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
1283
+ /** A version exactly as dsh's exemption records spell one: canonical semver,
1284
+ * build metadata included (app-boot's `isExactPluginVersion`). */
1285
+ function isExactVersion(value) {
1286
+ const parsed = parse(value);
1287
+ return parsed !== null && value === `${parsed.version}${parsed.build.length === 0 ? "" : `+${parsed.build.join(".")}`}`;
1288
+ }
1289
+ /**
1290
+ * The command that records dsh's exact-version exemption for one refused
1291
+ * install, spelled as dsh's own CLI prints it after a refusal — or null when
1292
+ * `dsh plugin allow-version` would refuse the package or either version,
1293
+ * which it checks against the grammar above. The name and version come from
1294
+ * the catalog, which is hostile input bound for a terminal, and the two
1295
+ * grammars admit no whitespace, quote, `$`, backtick, `;`, `|`, `&`,
1296
+ * redirection or bracket, so no catalog value can add a command to the line
1297
+ * it is pasted into.
1298
+ *
1299
+ * The profile is written as it is, as dsh prints it: it is the reader's own
1300
+ * choice of name, never catalog input.
1301
+ */
1302
+ function allowVersionCommand(profile, issue) {
1303
+ if (!EXEMPTION_PACKAGE_NAME.test(issue.name) || !isExactVersion(issue.version) || !isExactVersion(issue.runtimeVersion)) return null;
1304
+ return `dsh plugin --profile ${profile} allow-version ${issue.name}@${issue.version} --dsh-version ${issue.runtimeVersion} --accept-risk`;
1305
+ }
1306
+ //#endregion
1037
1307
  //#region src/host/dsh-cli.ts
1038
1308
  /**
1039
1309
  * How to start the dsh CLI as a child process.
@@ -1064,6 +1334,16 @@ const DSH_PACKAGE = "@deepseek-ai/dsh";
1064
1334
  * also the unscoped name its plain-string `bin` form would take. */
1065
1335
  const DSH_BIN_NAME = "dsh";
1066
1336
  /**
1337
+ * Whether dsh's CLI refuses this profile outright: `desktop`, in any letter
1338
+ * case, "is managed exclusively by the Electron application" — refused for a
1339
+ * launch and for `dsh plugin` alike (`rejectElectronProfile` in the CLI's
1340
+ * `lib/bin.js`, the same in 0.1.5-rc.3 and 0.1.7-rc.2). Every command the
1341
+ * shop spawns names a profile, so none of them can run for this one.
1342
+ */
1343
+ function isDesktopProfile(profile) {
1344
+ return profile.toLowerCase() === "desktop";
1345
+ }
1346
+ /**
1067
1347
  * A `dshBin` that names a JavaScript entry rather than a program.
1068
1348
  *
1069
1349
  * Such a file cannot be spawned anywhere: Windows needs a PE image, and POSIX
@@ -1335,6 +1615,93 @@ const FAILURE_LOG_NOISE = [
1335
1615
  /^\s+at /,
1336
1616
  /^<anonymous(?:_script)?>/
1337
1617
  ];
1618
+ /** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
1619
+ * `rejected`), and the exemption command its CLI prints after it, one per
1620
+ * refused package (`runPlugin`). */
1621
+ const REFUSAL_OPENER = "dsh: installation rejected: ";
1622
+ const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
1623
+ const REFUSAL_FAILED = "dsh: plugin command failed";
1624
+ /** Where dsh's warning turns from the refused peers to its risk statement
1625
+ * and remedy (app-boot's `pluginCompatibilityWarning`); the detail states
1626
+ * the risk once and gives the command instead. */
1627
+ const REFUSAL_WARNING_TAIL = " Running it may cause crashes or data loss.";
1628
+ /**
1629
+ * dsh's own refusal of an install (0.1.7 on) as one detail, or null when the
1630
+ * log holds none. The install was dsh's to refuse, not pnpm's to fail: it
1631
+ * refuses before pnpm runs, when it can read the manifest — a registry spec,
1632
+ * which the catalog's peer verdict already predicts — and after pnpm
1633
+ * succeeded otherwise, restoring the profile. Reading that as a pnpm failure
1634
+ * sent the reader to `dsh plugin install`, which repairs nothing.
1635
+ *
1636
+ * The detail is what dsh refused, what it restored, and the command that
1637
+ * records its exact-version exemption, rebuilt from what dsh printed through
1638
+ * `allowVersionCommand` rather than repeated: dsh prints the INSTALLED
1639
+ * manifest's own name into it, which a tarball or git package chooses
1640
+ * itself, so only a name and versions dsh's exemption grammar accepts reach
1641
+ * a line meant for a terminal. The profile is the one this install ran in.
1642
+ * Anything dsh prints after `plugin command failed` — the diagnostics path,
1643
+ * and a hint about git build scripts dsh prints after every failed git
1644
+ * install — says nothing about this refusal.
1645
+ */
1646
+ function refusalDetail(profile, usable) {
1647
+ const start = usable.findIndex((line) => line.startsWith(REFUSAL_OPENER));
1648
+ if (start === -1) return null;
1649
+ const refusedAt = usable.slice(start);
1650
+ const failedAt = refusedAt.findIndex((line) => line.startsWith(REFUSAL_FAILED));
1651
+ const block = failedAt === -1 ? refusedAt : refusedAt.slice(0, failedAt);
1652
+ const warnings = [];
1653
+ const commands = [];
1654
+ let restoration;
1655
+ for (const [index, line] of block.entries()) {
1656
+ const command = REFUSAL_COMMAND.exec(line);
1657
+ if (command !== null) {
1658
+ const [, name = "", version = "", runtimeVersion = ""] = command;
1659
+ const rebuilt = allowVersionCommand(profile, {
1660
+ name,
1661
+ version,
1662
+ runtimeVersion
1663
+ });
1664
+ if (rebuilt !== null) commands.push(rebuilt);
1665
+ } else if (index === 0) warnings.push(line.slice(28));
1666
+ else if (line.startsWith("dsh: ")) restoration ??= line.slice(5);
1667
+ else if (restoration === void 0) warnings.push(line);
1668
+ }
1669
+ const parts = [`dsh refused the install: ${warnings.map((warning) => {
1670
+ const tail = warning.indexOf(REFUSAL_WARNING_TAIL);
1671
+ return tail === -1 ? warning : warning.slice(0, tail);
1672
+ }).join(" ")}`];
1673
+ if (restoration !== void 0) parts.push(`${restoration.charAt(0).toUpperCase()}${restoration.slice(1)}`);
1674
+ if (commands.length > 0) parts.push(`To accept the risk of crashes or data loss for ${commands.length === 1 ? "this exact version" : "these exact versions"}, run: ${commands.join("; ")} — then install again.`);
1675
+ return parts.join(" ");
1676
+ }
1677
+ /** What Rust's allocator prints before it aborts, which is how pnpm 12's
1678
+ * native binary dies when it cannot get the memory it asked for. Anchored at
1679
+ * both ends: node's own heap exhaustion ("Allocation failed - JavaScript heap
1680
+ * out of memory") is a different crash, from pnpm 11 or dsh itself, and the
1681
+ * pnpm 12 remedy below would be wrong for it. */
1682
+ const NATIVE_ALLOCATION_ABORT = /^memory allocation of \d+ bytes failed$/;
1683
+ /**
1684
+ * pnpm 12 aborting out of memory as one detail, or null when the log holds no
1685
+ * such abort.
1686
+ *
1687
+ * The known cause is pnpm/pnpm#15362, fixed in pnpm 12.7.0: the native peer
1688
+ * check intersected the ranges of a missing peer, doubling the intersection
1689
+ * for every package that wanted it. dsh writes `autoInstallPeers: false` into
1690
+ * every profile, so every `@deepseek-ai/dsh*` peer stays missing and plugins
1691
+ * that share one trip it. Reported on Windows with 8 GB, reproduced on Linux
1692
+ * through the real CLI of dsh 0.1.5-rc.3 and 0.1.7-rc.2 (2026-09-27). The
1693
+ * install aborted, so running it again on the same profile aborts again:
1694
+ * the detail names the way out instead of the usual recovery hint.
1695
+ *
1696
+ * Not every such abort is that bug (pnpm/pnpm#15867 is another one, with the
1697
+ * same words), so the detail says what to do if the reader's pnpm already has
1698
+ * the fix.
1699
+ */
1700
+ function nativeAllocationAbortDetail(usable) {
1701
+ const abort = usable.find((line) => NATIVE_ALLOCATION_ABORT.test(line.trim()));
1702
+ if (abort === void 0) return null;
1703
+ return `pnpm crashed: its native binary ran out of memory (${abort.trim()}), which is not caused by this plugin. pnpm 12 before 12.7.0 has a known bug that exhausts memory when many packages share a missing peer, which every dsh profile has, because dsh keeps peers uninstalled (autoInstallPeers: false; pnpm/pnpm#15362). Upgrade pnpm to 12.7.0 or later, or use pnpm 11, then install again. If your pnpm is already 12.7.0 or later, report the line above to pnpm.`;
1704
+ }
1338
1705
  /**
1339
1706
  * The one log line worth putting in front of the user, plus the recovery hint.
1340
1707
  *
@@ -1352,6 +1719,10 @@ const FAILURE_LOG_NOISE = [
1352
1719
  function installFailureDetail(profile, log) {
1353
1720
  const hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`;
1354
1721
  const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
1722
+ const refusal = refusalDetail(profile, usable);
1723
+ if (refusal !== null) return refusal;
1724
+ const abort = nativeAllocationAbortDetail(usable);
1725
+ if (abort !== null) return abort;
1355
1726
  const reversed = [...usable].reverse();
1356
1727
  const pick = reversed.find((line) => /ERR_[A-Z][A-Z_]*/.test(line)) ?? reversed.find((line) => /(?:^|\s)\w*Error:/.test(line)) ?? usable[usable.length - 1];
1357
1728
  if (pick === void 0) return `${hint}.`;
@@ -1753,6 +2124,47 @@ function startUninstall(options) {
1753
2124
  });
1754
2125
  }
1755
2126
  //#endregion
2127
+ //#region src/host/bundle-patch.ts
2128
+ /**
2129
+ * The patch files a bundle declares, read the way dsh reads them (design
2130
+ * 2026-09-26-dsh-017-readiness, B4).
2131
+ *
2132
+ * dsh 0.1.7's app-boot (`bundlePatchFiles`) accepts `dsh.bundle.patch` as one
2133
+ * package-relative file or as a list of them, applied in order, and throws on
2134
+ * anything else. dsh 0.1.5 reads only the string form: it joins the
2135
+ * declaration onto the package directory, so a list makes the profile fail to
2136
+ * start. What the RUNNING dsh accepts is `RunningHarness.patchLists`; this
2137
+ * reads the declaration itself, for the shop's own readers of a package's
2138
+ * patch — the entries it owns, the rows a hot mount replicates.
2139
+ */
2140
+ /**
2141
+ * The declared patch files in application order: one for a string, the
2142
+ * listed ones for a list of strings. Null for a declaration dsh refuses —
2143
+ * neither a path nor a list of paths — which is distinct from an absent one:
2144
+ * only absence means the conventional `cordis.patch.yml`, and a caller that
2145
+ * fell back to it for a refused declaration would act on a patch dsh never
2146
+ * applies. Pure, and the list is a copy.
2147
+ */
2148
+ function bundlePatchFiles(declared) {
2149
+ if (typeof declared === "string") return [declared];
2150
+ if (Array.isArray(declared) && declared.every((file) => typeof file === "string")) return [...declared];
2151
+ return null;
2152
+ }
2153
+ /**
2154
+ * Whether an installed package's `dsh.bundle.patch` would stop the profile
2155
+ * from starting at the next boot, given whether the running dsh reads a list
2156
+ * (`RunningHarness.patchLists`). An absent declaration is no hazard: dsh adds
2157
+ * a package to the profile's bundles only when it declares a patch, so
2158
+ * nothing reads the field. An unknown harness (null) forms no verdict on a
2159
+ * list — the rule for every harness read — but a malformed declaration is
2160
+ * refused by every dsh, so it is named whatever is known. Pure.
2161
+ */
2162
+ function patchDeclarationHazard(declared, patchLists) {
2163
+ if (declared === void 0) return null;
2164
+ if (bundlePatchFiles(declared) === null) return "malformed";
2165
+ return Array.isArray(declared) && patchLists === false ? "list-unsupported" : null;
2166
+ }
2167
+ //#endregion
1756
2168
  //#region src/host/hot.ts
1757
2169
  /**
1758
2170
  * Restart-free installs: mount a freshly installed plugin into the running
@@ -1878,15 +2290,14 @@ function nextHotNumber(fs, dir) {
1878
2290
  }
1879
2291
  return max + 1;
1880
2292
  }
1881
- /** Read the installed package's own `dsh` section to locate its bundle patch
1882
- * (the Include input), or null when the package or the field is absent —
1883
- * the "no patch file" rejection names this. */
1884
- function readPkgDsh(fs, packageDir) {
2293
+ /** The installed package's own `dsh.bundle.patch`, exactly as declared (the
2294
+ * Include input), or undefined when the package, its manifest or the field
2295
+ * is absent. */
2296
+ function declaredPatch(fs, packageDir) {
1885
2297
  try {
1886
- const patch = JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
1887
- return typeof patch === "string" ? { patch } : null;
2298
+ return JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
1888
2299
  } catch {
1889
- return null;
2300
+ return;
1890
2301
  }
1891
2302
  }
1892
2303
  /** Load the Include class through a computed dynamic import, or null when
@@ -1924,33 +2335,45 @@ function suppressWrite(treeClass) {
1924
2335
  async function hotMount(ctx, profileDir, packageName, deps = {}) {
1925
2336
  const { fs = nodeHotFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
1926
2337
  const packageDir = join(profileDir, "node_modules", packageName);
1927
- const dsh = readPkgDsh(fs, packageDir);
1928
- const patchFile = resolve(packageDir, dsh?.patch ?? "cordis.patch.yml");
1929
- const inside = relative(packageDir, patchFile);
1930
- if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
1931
- ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
1932
- return {
1933
- ok: false,
1934
- reason: "no-patch"
1935
- };
1936
- }
1937
- let patchText;
1938
- try {
1939
- patchText = fs.read(patchFile);
1940
- } catch {
1941
- ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
2338
+ const declared = declaredPatch(fs, packageDir);
2339
+ const files = declared === void 0 ? ["cordis.patch.yml"] : bundlePatchFiles(declared);
2340
+ if (files === null || files.length === 0) {
2341
+ ctx.logger?.warn(`hot-mount ${packageName}: the bundle patch is declared as neither a file path nor a list of them — restart will activate it`);
1942
2342
  return {
1943
2343
  ok: false,
1944
2344
  reason: "no-patch"
1945
2345
  };
1946
2346
  }
1947
- const rows = parseSimplePatch(patchText);
1948
- if (rows === null) {
1949
- ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
1950
- return {
1951
- ok: false,
1952
- reason: "not-simple"
1953
- };
2347
+ const rows = [];
2348
+ for (const file of files) {
2349
+ const patchFile = resolve(packageDir, file);
2350
+ const inside = relative(packageDir, patchFile);
2351
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
2352
+ ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
2353
+ return {
2354
+ ok: false,
2355
+ reason: "no-patch"
2356
+ };
2357
+ }
2358
+ let patchText;
2359
+ try {
2360
+ patchText = fs.read(patchFile);
2361
+ } catch {
2362
+ ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
2363
+ return {
2364
+ ok: false,
2365
+ reason: "no-patch"
2366
+ };
2367
+ }
2368
+ const parsed = parseSimplePatch(patchText);
2369
+ if (parsed === null) {
2370
+ ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
2371
+ return {
2372
+ ok: false,
2373
+ reason: "not-simple"
2374
+ };
2375
+ }
2376
+ rows.push(...parsed);
1954
2377
  }
1955
2378
  let treeClass = deps.hotTreeClass;
1956
2379
  if (treeClass === void 0) treeClass = await loadHotTreeClass();
@@ -2061,18 +2484,21 @@ function cleanHotDir(profileDir) {
2061
2484
  * - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
2062
2485
  * entry the registry already enumerates, and the served graph follows it
2063
2486
  * within seconds (§2, measured 2026-09-11). `true`.
2064
- * - A HOT MOUNT — an install or an update — adds to the live loader entries
2065
- * without entering that composition, so the graph a tab reloads into does
2066
- * not contain the package. Measured 2026-09-14 against dsh 0.1.5-rc.1 in
2067
- * `web-full-flow.e2e.ts`: across a reload following a hot mount the graph
2068
- * is byte-identical, same `rev`, while the package's host half is live the
2069
- * whole time. `false` — and a `restart` is then the only honest answer,
2070
- * because there is nothing a reload could fetch.
2487
+ * - A HOT MOUNT — an install — adds entries under the shop's own loader
2488
+ * entry, which the registry enumerates like any other: across a reload
2489
+ * following the mount the served graph gains the package and the page runs
2490
+ * its browser half (measured 2026-09-26 on 0.1.5-rc.3 and 0.1.7-rc.2, in
2491
+ * `web-full-flow.e2e.ts`). `true`.
2071
2492
  *
2072
2493
  * The asymmetry that decides every unknown still runs the same way: offering
2073
2494
  * a step that was not needed costs the reader one action, withholding one
2074
- * that was needed is the defect this module exists to fix. What changed on
2075
- * 2026-09-14 is which step is the needed one after a hot mount.
2495
+ * that was needed is the defect this module exists to fix. Which step a hot
2496
+ * mount needs has been measured twice. On 2026-09-14 the graph came back
2497
+ * byte-identical across the reload, and installs moved to `restart` — but the
2498
+ * shop then registered the tree from whichever context made the RPC call, the
2499
+ * typert gateway's, and the registry never composed a tree hung there. Once
2500
+ * the tree hung off the shop's own entry (2026-09-26), the same measurement
2501
+ * found the package in the graph, and installs moved back to `reload`.
2076
2502
  */
2077
2503
  function activationOf(input) {
2078
2504
  if (!input.hostLive) return "restart";
@@ -2294,18 +2720,14 @@ function writeRepoPins(fs, path, pins) {
2294
2720
  //#endregion
2295
2721
  //#region src/host/profile.ts
2296
2722
  /** Profile directory discovery and user-layer writes (§8: hot enable/disable). */
2297
- function isJsExpr(value) {
2298
- return typeof value === "object" && value !== null && typeof value.__jsExpr === "string";
2299
- }
2300
- const JS_EXPR_TYPE = new Type("tag:yaml.org,2002:js", {
2301
- kind: "scalar",
2302
- resolve: () => true,
2303
- construct: (data) => ({ __jsExpr: data }),
2304
- predicate: isJsExpr,
2305
- represent: (value) => value.__jsExpr
2306
- });
2307
- /** js-yaml's default schema plus the user-layer `!!js` round trip. */
2308
- const USER_LAYER_SCHEMA = DEFAULT_SCHEMA.extend([JS_EXPR_TYPE]);
2723
+ /** The loader's `!!js` expression scalar, for the document edit: read as its
2724
+ * source text and written back under its own tag, never evaluated — the
2725
+ * loader evaluates it at entry activation. The same declaration the harness's
2726
+ * own writer of this file uses (`@deepseek-ai/dsh-plugin-manager` 0.1.7). */
2727
+ const USER_LAYER_TAGS = [{
2728
+ tag: "tag:yaml.org,2002:js",
2729
+ resolve: (source) => source
2730
+ }];
2309
2731
  /**
2310
2732
  * Find the profile directory that owns `startPath`.
2311
2733
  *
@@ -2364,22 +2786,78 @@ function realpathNearestExisting(startPath) {
2364
2786
  * {@link ownedEntryIds}), and they toggle together: writing them one at a
2365
2787
  * time would read-modify-write the file once per entry, so a crash between
2366
2788
  * two writes would leave the package half disabled.
2789
+ *
2790
+ * The edit changes one key and nothing else (design
2791
+ * 2026-09-26-market-borrowings §2). The layer is validated by the harness's
2792
+ * own parser first, so one it cannot load still throws and is never
2793
+ * rewritten; it is then edited as a YAML document, which keeps every comment,
2794
+ * every other row, every other key and every `!!js` scalar as the user wrote
2795
+ * them. The whole-file rewrite this replaced dropped each toggled id's row —
2796
+ * and with it a `config:` override the user had put there — and dumped the
2797
+ * list back through a library that models no comments, so the first toggle
2798
+ * erased the header dsh writes into every new profile.
2799
+ *
2800
+ * For each entry the row written is the LAST one carrying its id, when the
2801
+ * harness is sure to apply it: applyEntryPatches applies rows in order and
2802
+ * each key replaces the entry's value, so the last row that sets `disabled`
2803
+ * decides it. A row with `insert` is an insertion into a group, never an
2804
+ * override; a row naming another module is skipped by the harness; a named
2805
+ * row whose module the caller did not say cannot be judged. In each of those
2806
+ * cases a row is appended instead, which the harness applies last whatever
2807
+ * came before it — so appending is never wrong, only less tidy.
2808
+ *
2809
+ * An enable writes `disabled: false` rather than deleting the row, which is
2810
+ * what lost a user's row or the comment above it; it is also the harness's
2811
+ * own convention for this file from 0.1.7. The file keeps its permission bits
2812
+ * across the rename (a new one is created owner-only, as the harness's writer
2813
+ * does), because a config override can hold a credential.
2367
2814
  */
2368
2815
  function setUserLayerRows(options) {
2369
2816
  const file = join(options.profileDir, "cordis.patch.yml");
2370
- const existing = loadOptionalPatches("dsh-plugin-shop", file) ?? [];
2371
- const touched = new Set(options.rows.map((row) => row.id));
2372
- const next = [...existing.filter((row) => !touched.has(row.id)), ...options.rows.filter((row) => row.disabled).map((row) => ({
2373
- id: row.id,
2374
- disabled: true
2375
- }))];
2817
+ loadOptionalPatches("dsh-plugin-shop", file);
2818
+ let text = "[]\n";
2819
+ let mode = 384;
2820
+ if (existsSync(file)) {
2821
+ text = readFileSync(file, "utf8");
2822
+ mode = statSync(file).mode & 511;
2823
+ }
2824
+ const document = parseDocument(text, { customTags: USER_LAYER_TAGS });
2825
+ const parseError = document.errors[0];
2826
+ if (parseError !== void 0) throw parseError;
2827
+ const list = document.contents;
2828
+ if (!isSeq(list)) throw new Error(`dsh-plugin-shop: ${file} is not a YAML list of patch rows`);
2829
+ for (const row of options.rows) {
2830
+ const target = lastRowFor(list.items, row.id);
2831
+ if (target !== null && appliesTo(target, row.name)) {
2832
+ target.set("disabled", row.disabled);
2833
+ continue;
2834
+ }
2835
+ if (list.items.length === 0) list.flow = false;
2836
+ document.add({
2837
+ id: row.id,
2838
+ disabled: row.disabled
2839
+ });
2840
+ }
2376
2841
  const tmp = `${file}.tmp`;
2377
- writeFileSync(tmp, dump(next, {
2378
- noRefs: true,
2379
- schema: USER_LAYER_SCHEMA
2380
- }));
2842
+ writeFileSync(tmp, document.toString({ lineWidth: 0 }), { mode });
2843
+ chmodSync(tmp, mode);
2381
2844
  renameSync(tmp, file);
2382
2845
  }
2846
+ /** The last override row carrying `id`, or null when there is none. A row
2847
+ * with `insert` is an insertion into group `id`, not an override of it. */
2848
+ function lastRowFor(items, id) {
2849
+ for (let index = items.length - 1; index >= 0; index--) {
2850
+ const item = items[index];
2851
+ if (isMap(item) && !item.has("insert") && item.get("id") === id) return item;
2852
+ }
2853
+ return null;
2854
+ }
2855
+ /** Whether the harness is sure to apply `row` to the entry mounting
2856
+ * `moduleName`: a row with no `name`, or one naming that very module. */
2857
+ function appliesTo(row, moduleName) {
2858
+ if (!row.has("name")) return true;
2859
+ return moduleName !== void 0 && row.get("name") === moduleName;
2860
+ }
2383
2861
  /**
2384
2862
  * The loader entry ids one installed package contributes to the tree.
2385
2863
  *
@@ -2402,41 +2880,73 @@ function setUserLayerRows(options) {
2402
2880
  * which only the inventory can answer.
2403
2881
  */
2404
2882
  function ownedEntryIds(options) {
2883
+ return ownedEntries(options).map((entry) => entry.id);
2884
+ }
2885
+ /**
2886
+ * {@link ownedEntryIds} with the module each entry mounts beside its id. The
2887
+ * name is what the harness judges a NAMED user-layer row by — a row naming a
2888
+ * different module is skipped (applyEntryPatches) — so it is what lets the
2889
+ * toggle write a user's own named row rather than append one beside it. It is
2890
+ * read through the harness's parser, so a relative name arrives anchored to a
2891
+ * file URL exactly as the composed entry carries it; an `!!js` name has no
2892
+ * plain value and reads as undefined. Throws where `ownedEntryIds` does.
2893
+ */
2894
+ function ownedEntries(options) {
2405
2895
  const packageDir = join(options.profileDir, "node_modules", ...options.packageName.split("/"));
2406
2896
  const manifestPath = join(packageDir, "package.json");
2407
2897
  if (!existsSync(manifestPath)) return [];
2408
- let patchRelative;
2898
+ let declared;
2409
2899
  try {
2410
- patchRelative = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2900
+ declared = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2411
2901
  } catch (error) {
2412
2902
  throw new Error(`dsh-plugin-shop: failed to read ${manifestPath}: ${String(error)}`);
2413
2903
  }
2414
- if (typeof patchRelative !== "string") return [];
2415
- const patchFile = resolve(packageDir, patchRelative);
2416
- const inside = relative(packageDir, patchFile);
2417
- if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`dsh-plugin-shop: ${options.packageName} declares a bundle patch outside its own directory: ${patchRelative}`);
2418
- const ids = [];
2419
- collectInsertedIds(loadOptionalPatches("dsh-plugin-shop", patchFile) ?? [], ids);
2420
- return [...new Set(ids)];
2904
+ const files = bundlePatchFiles(declared);
2905
+ if (files === null) return [];
2906
+ const entries = [];
2907
+ for (const file of files) {
2908
+ const patchFile = resolve(packageDir, file);
2909
+ const inside = relative(packageDir, patchFile);
2910
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`dsh-plugin-shop: ${options.packageName} declares a bundle patch outside its own directory: ${file}`);
2911
+ collectInsertedEntries(loadOptionalPatches("dsh-plugin-shop", patchFile) ?? [], entries);
2912
+ }
2913
+ const seen = /* @__PURE__ */ new Set();
2914
+ return entries.filter((entry) => !seen.has(entry.id) && seen.add(entry.id) !== void 0);
2421
2915
  }
2422
- /** Walk a patch list, appending the id of every INSERTED entry. A patch row
2423
- * without `insert` targets an entry someone else composed — the loader's
2916
+ /**
2917
+ * An installed package's own `dsh.bundle.patch`, exactly as declared, or
2918
+ * undefined when the package, its manifest or the field is absent — or the
2919
+ * manifest cannot be read, which no post-install check can judge either.
2920
+ */
2921
+ function declaredBundlePatch(options) {
2922
+ const manifestPath = join(options.profileDir, "node_modules", ...options.packageName.split("/"), "package.json");
2923
+ try {
2924
+ return JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2925
+ } catch {
2926
+ return;
2927
+ }
2928
+ }
2929
+ /** Walk a patch list, appending every INSERTED entry. A patch row without
2930
+ * `insert` targets an entry someone else composed — the loader's
2424
2931
  * applyEntryPatches looks it up and skips it when absent, so it creates
2425
2932
  * nothing and owns nothing. An inserted GROUP owns its children, which the
2426
2933
  * loader reads from the group's own `config` array. */
2427
- function collectInsertedIds(rows, into) {
2934
+ function collectInsertedEntries(rows, into) {
2428
2935
  for (const row of rows) {
2429
2936
  if (row === null || typeof row !== "object") continue;
2430
2937
  const inserted = row.insert;
2431
- if (Array.isArray(inserted)) collectEntryIds(inserted, into);
2938
+ if (Array.isArray(inserted)) collectEntries(inserted, into);
2432
2939
  }
2433
2940
  }
2434
- function collectEntryIds(entries, into) {
2941
+ function collectEntries(entries, into) {
2435
2942
  for (const entry of entries) {
2436
2943
  if (entry === null || typeof entry !== "object") continue;
2437
- const { id, group, config } = entry;
2438
- if (typeof id === "string") into.push(id);
2439
- if (group === true && Array.isArray(config)) collectEntryIds(config, into);
2944
+ const { id, name, group, config } = entry;
2945
+ if (typeof id === "string") into.push({
2946
+ id,
2947
+ name: typeof name === "string" ? name : void 0
2948
+ });
2949
+ if (group === true && Array.isArray(config)) collectEntries(config, into);
2440
2950
  }
2441
2951
  }
2442
2952
  /**
@@ -2820,25 +3330,240 @@ function createPrefetcher(options = {}) {
2820
3330
  * not provide (design 2026-09-01-harness-compatibility), and — for the
2821
3331
  * shop's OWN declared peers — which ones it provides at a version outside
2822
3332
  * the declared range. */
3333
+ const NODE_LOOKUP_FS = {
3334
+ stat: (path) => statSync(path),
3335
+ readFile: (path) => readFileSync(path, "utf8")
3336
+ };
3337
+ /**
3338
+ * One segment of a bare package name, about to be spliced into a path.
3339
+ *
3340
+ * A leading `.` refuses the segment whole: `.` and `..` would walk the path
3341
+ * out of `node_modules`, and `.bin` or `.pnpm` are directories an install
3342
+ * carries that no package can be. The ESM resolver agrees as far as it goes:
3343
+ * it refuses an unscoped name that starts with `.`, and any name holding `%`
3344
+ * or `\`, with ERR_INVALID_MODULE_SPECIFIER (measured on Node 26.6.0 with
3345
+ * `.bin`, `a%2e`, `a\b`). `\` is also a separator on Windows, `:` names a
3346
+ * drive, an NTFS stream or a URL scheme (`node:fs`, `file:`), and NUL is a
3347
+ * byte no path may carry.
3348
+ */
3349
+ function isNameSegment(segment) {
3350
+ return segment.length > 0 && !segment.startsWith(".") && !/[\\:%\0]/.test(segment);
3351
+ }
2823
3352
  /**
2824
- * The production resolver: the same question the harness's own
2825
- * ClientModuleRegistry asks, through a require anchored at the profile. Asking
2826
- * what the loader asks is what keeps this verdict and the runtime's behaviour
2827
- * from drifting apart.
3353
+ * Whether `spec` is a bare package name — `name`, or `@scope/name` — and so
3354
+ * safe to look up. Peer names are catalog input, and hostile; this check is
3355
+ * what keeps one from becoming a path. The lookup this replaced resolved
3356
+ * `'../x/package.json'` relative to the profile and an absolute name as
3357
+ * itself, so a peer name could point it at any path on the reader's disk.
3358
+ */
3359
+ function isBarePackageName(spec) {
3360
+ const segments = spec.split("/");
3361
+ const [first, second] = segments;
3362
+ if (first === void 0) return false;
3363
+ if (!first.startsWith("@")) return segments.length === 1 && isNameSegment(first);
3364
+ return segments.length === 2 && second !== void 0 && isNameSegment(first.slice(1)) && isNameSegment(second);
3365
+ }
3366
+ /** Whether `path` stats as a directory. Every stat failure answers false —
3367
+ * see `findPackageDir` for why that is Node's rule and not a shortcut. */
3368
+ function isDirectoryAt(fs, path) {
3369
+ try {
3370
+ return fs.stat(path).isDirectory();
3371
+ } catch {
3372
+ return false;
3373
+ }
3374
+ }
3375
+ /** Whether `path` stats as a regular file. Every stat failure answers false,
3376
+ * for the reason `isDirectoryAt` gives. */
3377
+ function isFileAt(fs, path) {
3378
+ try {
3379
+ return fs.stat(path).isFile();
3380
+ } catch {
3381
+ return false;
3382
+ }
3383
+ }
3384
+ /**
3385
+ * The directory the ESM resolver MATCHES for `spec` from `fromDir`, or null
3386
+ * when the walk reaches the filesystem root without one: for each ancestor
3387
+ * `dir`, the candidate `dir/node_modules/<spec>`, and the first that stats as
3388
+ * a DIRECTORY is the match. A candidate that is anything else — absent, a
3389
+ * file, or a path whose stat fails for ANY reason — is "not here", and the
3390
+ * walk moves on to the next ancestor.
3391
+ *
3392
+ * That is Node's own rule, copied from its reader rather than from the spec
3393
+ * prose. Node 26.6.0 `internal/modules/package_json_reader.js`
3394
+ * (`getPackageJSONURL`) does
3395
+ * `const stat = internalModuleStat(...); if (stat !== 1) { ...; continue }`:
3396
+ * 1 is "directory", and a failed stat is a negative errno, so EACCES and
3397
+ * ELOOP keep walking exactly like ENOENT. Measured on 26.6.0: a self-link
3398
+ * (ELOOP) at `node_modules/<name>` in front of an ancestor's real copy makes
3399
+ * the import load the ancestor's copy. This walk used to throw on every
3400
+ * failure but ENOENT and ENOTDIR, and `incompatibilityMap` turned the throw
3401
+ * into no verdict — so a missing peer, whose walk runs all the way to the
3402
+ * root, met any unsearchable or looping `node_modules` on the way (a
3403
+ * `~/node_modules` made by `sudo npm i` under umask 027, say), and every
3404
+ * missing-peer badge in the catalog went silent while those plugins still
3405
+ * failed to import.
3406
+ *
3407
+ * The walk stops at the first match whatever it finds inside: the loader
3408
+ * imports from that directory or fails there, and never falls through to an
3409
+ * ancestor (measured on 26.6.0: an empty `node_modules/<name>` in front of an
3410
+ * ancestor's working copy fails the import with ERR_MODULE_NOT_FOUND).
3411
+ * Whether the match is a PACKAGE is `packageDirectory`'s question.
3412
+ *
3413
+ * Throws only for a `spec` that is not a bare package name. No state of the
3414
+ * filesystem can make it throw.
3415
+ */
3416
+ function findPackageDir(fromDir, spec, fs) {
3417
+ if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
3418
+ const segments = spec.split("/");
3419
+ for (let dir = fromDir;;) {
3420
+ const candidate = join(dir, "node_modules", ...segments);
3421
+ if (isDirectoryAt(fs, candidate)) return candidate;
3422
+ const parent = dirname(dir);
3423
+ if (parent === dir) return null;
3424
+ dir = parent;
3425
+ }
3426
+ }
3427
+ /**
3428
+ * The directory `spec` is installed at as seen from `fromDir`, or null when
3429
+ * it is not installed there: the walk's match (`findPackageDir`), and only
3430
+ * when that directory holds a `package.json` that stats as a FILE. Anything
3431
+ * else is ABSENT — a match with no manifest, a manifest that is not a file,
3432
+ * one whose stat fails — because the loader matches that directory and the
3433
+ * import fails there. The case this exists for: an uninstall that stops
3434
+ * part-way (on Windows a locked file is enough) can leave
3435
+ * `<profile>/node_modules/L/` holding nothing but a nested `node_modules/`,
3436
+ * and a plugin declaring L then fails with ERR_MODULE_NOT_FOUND; while any
3437
+ * directory counted as present, the badge naming L disappeared. The lookup
3438
+ * before that one, `require.resolve('<spec>/package.json')`, required the
3439
+ * manifest too.
3440
+ *
3441
+ * The manifest is stat-ed and never read here: presence is the file, and
3442
+ * what it says is the version resolver's question. So a manifest that exists
3443
+ * but cannot be read or parsed is a present package with no readable version.
3444
+ *
3445
+ * One edge is knowingly given up: a directory holding `index.js` and no
3446
+ * manifest DOES import — Node's legacy main resolution tries `index.js` in a
3447
+ * match without a manifest — but reads absent here. No package manager
3448
+ * installs that shape.
3449
+ *
3450
+ * Throws only for a `spec` that is not a bare package name. This one lookup
3451
+ * is behind the presence resolver, the version resolver, and the running
3452
+ * harness's own `@deepseek-ai/dsh-app-boot` (`harness.ts`).
3453
+ */
3454
+ function packageDirectory(fromDir, spec, fs = NODE_LOOKUP_FS) {
3455
+ const match = findPackageDir(fromDir, spec, fs);
3456
+ return match !== null && isFileAt(fs, join(match, "package.json")) ? match : null;
3457
+ }
3458
+ /**
3459
+ * The directory the walk starts from. `createRequire` took a file URL or an
3460
+ * absolute path and so does this — the production anchor is
3461
+ * `<profile>/cordis.yml` as a file URL — and, like `createRequire`, a
3462
+ * trailing separator names the directory itself rather than a file in its
3463
+ * parent. Anything else throws here, at construction, which the gateway's
3464
+ * call sites already treat as "no anchor, no verdict".
3465
+ */
3466
+ function anchorDirectory(baseUrl) {
3467
+ const path = isAbsolute(baseUrl) ? baseUrl : fileURLToPath(baseUrl);
3468
+ return path.endsWith("/") || path.endsWith(sep) ? path : dirname(path);
3469
+ }
3470
+ /** `nodeResolver` over an injected filesystem, walking up from `fromDir` —
3471
+ * the seam fixtures drive the lookup through. */
3472
+ function packageResolver(fromDir, fs) {
3473
+ return (spec) => packageDirectory(fromDir, spec, fs) !== null;
3474
+ }
3475
+ /**
3476
+ * The production resolver, anchored at the profile. A peer is present when
3477
+ * the first ancestor `node_modules/<spec>` that is a directory holds a
3478
+ * `package.json` file — the directory the ESM resolver that loads plugins'
3479
+ * host halves matches for a bare package, and a manifest in it
3480
+ * (`findPackageDir`, `packageDirectory`). From `<profile>/cordis.yml` the
3481
+ * walk reads `<profile>/node_modules`, then `$DSH_HOME/profiles/node_modules`
3482
+ * — the link farm dsh-app-boot's `healProfilesModuleFallback` keeps, pointing
3483
+ * into the global dsh install, on 0.1.5; 0.1.7 keeps none, and on a harness
3484
+ * that serves its packages another way `harnessPackageResolver` asks it in
3485
+ * front of this walk — and so on up to the root, past any candidate
3486
+ * it cannot stat, as Node's reader walks past one (measured on Node 26.6.0
3487
+ * with a looping link in front of a real copy: the import loads the copy).
3488
+ * The one shape where this and the loader knowingly disagree is a directory
3489
+ * holding `index.js` and no manifest, which imports and reads absent here;
3490
+ * no package manager installs it (`packageDirectory`).
3491
+ *
3492
+ * It used to ask `require.resolve('<spec>/package.json')`, a question that
3493
+ * answered wrongly twice:
3494
+ *
3495
+ * - Under a packaged dsh executable (`process.pkg`) the link farm holds ESM
3496
+ * proxy packages instead of symlinks, and a proxy's `exports` leaves
3497
+ * `./package.json` out on purpose, so resolving it threw
3498
+ * ERR_PACKAGE_PATH_NOT_EXPORTED for every proxied peer. Presence read that
3499
+ * as present; the version resolver below could read nothing. Reproduced:
3500
+ * a proxy at `0.2.0-rc.1` against a declared `^0.1.1-rc.2` gave presence
3501
+ * true, version null, mismatches `[]` — the load-time self-check could
3502
+ * never fire under a packaged dsh.
3503
+ * - Node caches a SUCCESSFUL CJS resolution for the life of the process
3504
+ * (`Module._pathCache`, keyed on request + lookup paths) and hands the
3505
+ * cached filename back without looking at the disk; a failure is not
3506
+ * cached. Measured on Node 26.6.0 in one process: absent → false,
3507
+ * installed → true, uninstalled → STILL true. A peer the reader removed
3508
+ * left every plugin declaring it unflagged until dsh restarted.
3509
+ *
3510
+ * So nothing is cached and `exports` is never consulted: every call stats
3511
+ * afresh, and a package with a manifest is present whatever its `exports`
3512
+ * says. `stat` follows symlinks, so a link to nothing reads absent — which
3513
+ * matters, because the link farm adds links and never prunes them: 29 of its
3514
+ * 511 dangled on the machine this was measured on, after upgrades.
3515
+ *
3516
+ * The global folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`,
3517
+ * `$PREFIX/lib/node` — are deliberately not searched: the ESM resolver that
3518
+ * loads plugin host code does not search them either. That is not academic.
3519
+ * pnpm's bin shims export `NODE_PATH` (this repo's own `node_modules/.bin/
3520
+ * vitest` points it into the virtual store), and under one the old lookup
3521
+ * found harness packages from any anchor at all, a temp directory included.
3522
+ *
3523
+ * It throws only for a `spec` that is not a bare package name, and
3524
+ * `incompatibilityMap` turns the throw into no verdict: a hostile name is not
3525
+ * a fact about this installation, and false is an accusation. Nothing on the
3526
+ * filesystem makes it throw. It used to throw on a stat failure other than
3527
+ * ENOENT or ENOTDIR as well, reasoning that such a candidate "may hold the
3528
+ * package" — but the loader never asks what a candidate it cannot stat holds;
3529
+ * it walks on (`findPackageDir`), so the throw silenced true badges and
3530
+ * established nothing.
2828
3531
  */
2829
3532
  function nodeResolver(baseUrl) {
2830
- const require = createRequire(baseUrl);
2831
- return (spec) => {
2832
- try {
2833
- require.resolve(`${spec}/package.json`);
2834
- return true;
2835
- } catch (error) {
2836
- const code = error.code;
2837
- if (code === "MODULE_NOT_FOUND") return false;
2838
- if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") return true;
2839
- throw error;
2840
- }
2841
- };
3533
+ return packageResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
3534
+ }
3535
+ /**
3536
+ * The manifest of the package the harness reaches for `spec` from
3537
+ * `parentURL`, or null when it reaches none, or one whose manifest no longer
3538
+ * stats as a file. The stat is fresh on every call for the reason §9.4 gives:
3539
+ * the service remembers a package it once found, and an uninstalled peer
3540
+ * must stop reading as present. Throws what the service throws, and a
3541
+ * TypeError for a name that is not a bare package name — which never reaches
3542
+ * the service, because peer names are catalog text.
3543
+ */
3544
+ function harnessManifest(lookup, spec, parentURL, fs) {
3545
+ if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
3546
+ const manifestPath = lookup.packageOf(spec, parentURL)?.manifestPath;
3547
+ return typeof manifestPath === "string" && isFileAt(fs, manifestPath) ? manifestPath : null;
3548
+ }
3549
+ /**
3550
+ * Presence on a harness that serves its packages through its runtime
3551
+ * resolution (`HarnessPackageLookup`): present when the harness reaches a
3552
+ * package for `spec` from `parentURL`, the profile anchor, and otherwise
3553
+ * whatever `walk` answers — `nodeResolver` over the same anchor, in
3554
+ * production. Asking the harness first asks the loader's own question, since
3555
+ * a plugin's import goes through the same resolution. The walk stays behind
3556
+ * it because either answer can only clear a badge, never raise one, and a
3557
+ * false badge is the failure this module exists to avoid.
3558
+ *
3559
+ * Throws, which `incompatibilityMap` turns into no verdict for the entries
3560
+ * declaring `spec`, for a name that is not a bare package name and for
3561
+ * anything the service throws. A harness that cannot answer is silence:
3562
+ * falling back to the walk alone would read every package it serves as
3563
+ * missing.
3564
+ */
3565
+ function harnessPackageResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
3566
+ return (spec) => harnessManifest(lookup, spec, parentURL, fs) !== null || walk(spec);
2842
3567
  }
2843
3568
  /**
2844
3569
  * Package name → the peer names that did not resolve. A key is present only
@@ -2876,29 +3601,79 @@ function incompatibilityMap(entries, resolve) {
2876
3601
  }
2877
3602
  return out;
2878
3603
  }
2879
- /**
2880
- * The production resolver: `nodeResolver`'s own resolution, kept this time
2881
- * instead of collapsed to a boolean. Anything that leaves no version to read
2882
- * answers null — a peer that is absent, one that restricts `./package.json`
2883
- * in its exports, an unreadable or malformed manifest, a manifest with no
2884
- * `version`. Absence is deliberately NOT reported as a version violation:
2885
- * `incompatibilityMap` is what covers a missing peer.
2886
- */
2887
- function nodeVersionResolver(baseUrl) {
2888
- const require = createRequire(baseUrl);
3604
+ /** `nodeVersionResolver` over an injected filesystem, walking up from
3605
+ * `fromDir` — the seam fixtures drive the lookup through. */
3606
+ function packageVersionResolver(fromDir, fs) {
2889
3607
  return (spec) => {
2890
- let manifestPath;
3608
+ let dir;
2891
3609
  try {
2892
- manifestPath = require.resolve(`${spec}/package.json`);
3610
+ dir = packageDirectory(fromDir, spec, fs);
2893
3611
  } catch {
2894
3612
  return null;
2895
3613
  }
3614
+ return dir === null ? null : manifestVersion(fs, join(dir, "package.json"));
3615
+ };
3616
+ }
3617
+ /** The non-empty `version` string of the manifest at `manifestPath`, or null
3618
+ * for anything else. */
3619
+ function manifestVersion(fs, manifestPath) {
3620
+ let manifest;
3621
+ try {
3622
+ manifest = JSON.parse(fs.readFile(manifestPath));
3623
+ } catch {
3624
+ return null;
3625
+ }
3626
+ const version = typeof manifest === "object" && manifest !== null ? manifest.version : void 0;
3627
+ return typeof version === "string" && version.length > 0 ? version : null;
3628
+ }
3629
+ /**
3630
+ * The production version resolver: `nodeResolver`'s lookup, then the matched
3631
+ * directory's own `package.json`, read directly. Reading it directly is the
3632
+ * fix, not a shortcut: resolving `<spec>/package.json` is what an exports map
3633
+ * can refuse, and a packaged dsh's module proxy refuses it by construction
3634
+ * while carrying the proxied package's version in exactly that file (see
3635
+ * `nodeResolver`).
3636
+ *
3637
+ * The directory is the one the presence resolver finds present — the walk's
3638
+ * first directory match, holding a `package.json` file (`packageDirectory`;
3639
+ * `findPackageDir` cites Node's reader) — so the two never disagree about
3640
+ * which copy is installed: a candidate nobody can stat is walked past to the
3641
+ * copy further up, whose version is then the answer, exactly as the loader
3642
+ * imports that copy (measured on Node 26.6.0 with a looping link in front of
3643
+ * it); a match without a manifest is absent, and shadows every copy above
3644
+ * it. An `index.js`-only directory, which the loader would import, has no
3645
+ * manifest to read a version from either way.
3646
+ *
3647
+ * Anything that leaves no version to read answers null: a spec that is not a
3648
+ * bare package name, a peer that is absent, a manifest that is unreadable or
3649
+ * malformed, a `version` that is not a non-empty string. Absence is
3650
+ * deliberately NOT reported as a version violation: `incompatibilityMap` is
3651
+ * what covers a missing peer.
3652
+ */
3653
+ function nodeVersionResolver(baseUrl) {
3654
+ return packageVersionResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
3655
+ }
3656
+ /**
3657
+ * `harnessPackageResolver`'s twin for versions: read afresh from the manifest
3658
+ * of the package the harness reaches for `spec` — the copy a plugin's import
3659
+ * loads, which the walk cannot see on dsh 0.1.7 — and `walk`'s answer only
3660
+ * when the harness reaches none. A manifest the harness names but nobody can
3661
+ * read or parse answers null, never the walk's version: the walk may match
3662
+ * another copy, and the version must describe the copy presence found (see
3663
+ * `nodeVersionResolver`).
3664
+ *
3665
+ * Null for a name that is not a bare package name and for a service that
3666
+ * throws, as null is already this resolver's no-verdict signal.
3667
+ */
3668
+ function harnessPackageVersionResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
3669
+ return (spec) => {
3670
+ let manifestPath;
2896
3671
  try {
2897
- const version = JSON.parse(readFileSync(manifestPath, "utf8")).version;
2898
- return typeof version === "string" ? version : null;
3672
+ manifestPath = harnessManifest(lookup, spec, parentURL, fs);
2899
3673
  } catch {
2900
3674
  return null;
2901
3675
  }
3676
+ return manifestPath === null ? walk(spec) : manifestVersion(fs, manifestPath);
2902
3677
  };
2903
3678
  }
2904
3679
  /**
@@ -2974,6 +3749,186 @@ function createPeerVersionCheck(deps) {
2974
3749
  };
2975
3750
  }
2976
3751
  //#endregion
3752
+ //#region src/host/harness.ts
3753
+ /**
3754
+ * The harness this process runs: which `@deepseek-ai/dsh` it is, and that
3755
+ * dsh's own profile-template table — the running side of the
3756
+ * `dsh.compatibility` verdict (design 2026-09-01-harness-compatibility §9.9).
3757
+ *
3758
+ * Both used to be read from somewhere that is not the running harness, and
3759
+ * both were wrong there:
3760
+ *
3761
+ * - The version was the first `node_modules/@deepseek-ai/dsh` found walking up
3762
+ * from the profile: what the profile can IMPORT, not what RUNS. A plugin
3763
+ * that depends on the dsh package gets its copy hoisted into
3764
+ * `<profile>/node_modules` — the listed `dsh-claude-tui@0.1.6` pulls
3765
+ * 0.1.2-rc.1 — a second install sharing DSH_HOME re-points the link farm,
3766
+ * and under a packaged dsh the bin-only package gets no link-farm proxy at
3767
+ * all, so the read was dead or stale. The first was reproduced with real
3768
+ * installs on 2026-09-25 (the design's section 9.9 amendment): every
3769
+ * verdict said "running 0.1.2-rc.1" while 0.1.5-rc.3 ran.
3770
+ * - The templates came from the shop's own `import * as appBoot`, which
3771
+ * resolves from the SHOP's real path: under a `link:` install that is this
3772
+ * repository's devDependency, 0.1.1-rc.2 with two templates, while
3773
+ * 0.1.5-rc.3 with five runs — and in production a copy hoisted from some
3774
+ * plugin's dependencies can shadow it the same way.
3775
+ *
3776
+ * Both now come from the one thing that names the running harness: the script
3777
+ * this process was started with. Resolved through symlinks, the package that
3778
+ * owns it IS the dsh CLI that is running, if it is one at all; its manifest
3779
+ * says its version, and the app-boot it imports says its templates.
3780
+ */
3781
+ /** The package whose `PROFILE_TEMPLATES` the running dsh composes profiles
3782
+ * from. */
3783
+ const APP_BOOT = "@deepseek-ai/dsh-app-boot";
3784
+ /** What is known when nothing says which harness runs. */
3785
+ function noHarness() {
3786
+ return {
3787
+ dshVersion: null,
3788
+ templates: profileTemplatesOf(void 0),
3789
+ patchLists: null,
3790
+ peerCheck: null
3791
+ };
3792
+ }
3793
+ /** Whether `path` stats as a regular file. */
3794
+ function isFile(path) {
3795
+ try {
3796
+ return statSync(path).isFile();
3797
+ } catch {
3798
+ return false;
3799
+ }
3800
+ }
3801
+ /**
3802
+ * The package that owns `start`: the FIRST directory at or above it holding a
3803
+ * `package.json` file, and its parsed manifest — or null when there is none
3804
+ * up to the root, or when that manifest cannot be read or parsed. The walk
3805
+ * stops at the first manifest whatever it names, the rule `owningEntry` in
3806
+ * `dsh-cli.ts` follows: a script owned by some other package is not dsh's,
3807
+ * and climbing on would find an unrelated ancestor — a workspace root, or dsh
3808
+ * itself above a package vendored inside it.
3809
+ */
3810
+ function owningPackage(start) {
3811
+ for (let dir = start;;) {
3812
+ const manifestPath = join(dir, "package.json");
3813
+ if (isFile(manifestPath)) try {
3814
+ return {
3815
+ dir,
3816
+ manifest: JSON.parse(readFileSync(manifestPath, "utf8"))
3817
+ };
3818
+ } catch {
3819
+ return null;
3820
+ }
3821
+ const parent = dirname(dir);
3822
+ if (parent === dir) return null;
3823
+ dir = parent;
3824
+ }
3825
+ }
3826
+ /**
3827
+ * What the app-boot the dsh at `dshDir` imports says about that dsh: its
3828
+ * template table, and whether it reads a list-valued bundle patch. Null when
3829
+ * no app-boot is on the resolution path.
3830
+ *
3831
+ * Found with the node_modules walk the peer check uses (`packageDirectory`)
3832
+ * from the dsh package directory — the copy nested under dsh first, then one
3833
+ * beside it, as dsh's own import finds it — and never through `NODE_PATH`:
3834
+ * pnpm's bin shims export one, and a bare CJS lookup finds a package through
3835
+ * it from any directory at all. Only the ABSOLUTE directory then reaches
3836
+ * Node's CJS resolver, which reads its `main` (an `exports` map applies to
3837
+ * bare specifiers only), and the entry is imported by file URL, which Windows
3838
+ * requires of an absolute path. Under the dsh that runs, that URL is the one
3839
+ * dsh itself loaded — the CJS resolver realpaths as the ESM loader does, and
3840
+ * app-boot's `main` and `exports` name the same file — so the import is a
3841
+ * cache hit, not a second instance. Measured against a global npm install of
3842
+ * 0.1.5-rc.3: this chain and the ESM resolution from dsh's own `lib/bin.js`
3843
+ * give the same URL, and the reader, handed `<prefix>/bin/dsh`, answers
3844
+ * 0.1.5-rc.3 with all five templates.
3845
+ *
3846
+ * Throws for whatever that chain cannot do; `readRunningHarness` turns any
3847
+ * throw into an empty table, an unknown `patchLists` and no peer check.
3848
+ */
3849
+ async function appBootFacts(dshDir) {
3850
+ const appBootDir = packageDirectory(dshDir, APP_BOOT);
3851
+ if (appBootDir === null) return null;
3852
+ const entry = createRequire(import.meta.url).resolve(appBootDir);
3853
+ const exported = await import(pathToFileURL(entry).href);
3854
+ return {
3855
+ templates: profileTemplatesOf(exported.PROFILE_TEMPLATES),
3856
+ patchLists: typeof exported.bundlePatchFiles === "function",
3857
+ peerCheck: peerCheckOf(exported, appBootDir)
3858
+ };
3859
+ }
3860
+ /**
3861
+ * The peer check an app-boot exports, bound to its own version (see
3862
+ * `PeerCheck.evaluate`), or null when it exports only part of one — a rule
3863
+ * without the exemptions would refuse what a profile has already allowed —
3864
+ * or when its manifest carries no version, which is the one `evaluate`
3865
+ * judges by. Never throws, so a check that cannot be formed costs the
3866
+ * templates and the patch-list answer nothing.
3867
+ */
3868
+ function peerCheckOf(exported, appBootDir) {
3869
+ const { evaluatePluginCompatibility: evaluate, readProfileVersionExemptions: exemptions } = exported;
3870
+ if (typeof evaluate !== "function" || typeof exemptions !== "function") return null;
3871
+ let version;
3872
+ try {
3873
+ version = JSON.parse(readFileSync(join(appBootDir, "package.json"), "utf8")).version;
3874
+ } catch {
3875
+ return null;
3876
+ }
3877
+ if (typeof version !== "string" || version.length === 0) return null;
3878
+ const runtime = version;
3879
+ const judge = evaluate;
3880
+ return {
3881
+ evaluate: (manifest, profileExemptions) => judge(manifest, profileExemptions, runtime),
3882
+ exemptions
3883
+ };
3884
+ }
3885
+ /**
3886
+ * The harness this process runs, identified from `script` — the gateway's
3887
+ * `restartScript`, `process.argv[1]` in production — or nothing, never a
3888
+ * guess:
3889
+ *
3890
+ * 1. `script` is resolved through symlinks, because `argv[1]` is the path the
3891
+ * shell ran — `<prefix>/bin/dsh`, a link — and only its target lies inside
3892
+ * the dsh package. No script, or one that does not resolve: nothing known.
3893
+ * An empty path is no script either: `realpathSync('')` answers the
3894
+ * working directory, which says where dsh was started, not what it is.
3895
+ * 2. The package that owns it (`owningPackage`) must be `@deepseek-ai/dsh`.
3896
+ * Anything else — a test runner, another host embedding the shop — means
3897
+ * nothing here can say which harness runs, and both halves of the verdict
3898
+ * stay silent.
3899
+ * 3. `dshVersion` is that manifest's `version` when it is a non-empty string.
3900
+ * 4. `templates`, `patchLists` and `peerCheck` come from that dsh's own
3901
+ * app-boot (`appBootFacts`). Any failure there costs all three and keeps
3902
+ * the version.
3903
+ *
3904
+ * Never throws and never rejects: a harness nobody could identify forms no
3905
+ * verdict, and must never be the reason the catalog fails to load.
3906
+ */
3907
+ async function readRunningHarness(script) {
3908
+ try {
3909
+ if (script === void 0 || script === "") return noHarness();
3910
+ const owner = owningPackage(dirname(realpathSync(script)));
3911
+ if (owner === null || typeof owner.manifest !== "object" || owner.manifest === null) return noHarness();
3912
+ const { name, version } = owner.manifest;
3913
+ if (name !== "@deepseek-ai/dsh") return noHarness();
3914
+ const dshVersion = typeof version === "string" && version.length > 0 ? version : null;
3915
+ let facts;
3916
+ try {
3917
+ facts = await appBootFacts(owner.dir);
3918
+ } catch {
3919
+ facts = null;
3920
+ }
3921
+ return {
3922
+ dshVersion,
3923
+ templates: facts?.templates ?? profileTemplatesOf(void 0),
3924
+ patchLists: facts?.patchLists ?? null,
3925
+ peerCheck: facts?.peerCheck ?? null
3926
+ };
3927
+ } catch {
3928
+ return noHarness();
3929
+ }
3930
+ }
3931
+ //#endregion
2977
3932
  //#region src/host/index.ts
2978
3933
  /** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
2979
3934
  var __runInitializers = function(thisArg, initializers, value) {
@@ -3026,10 +3981,16 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
3026
3981
  * service and to set an override that the platform check, being the first gate
3027
3982
  * of the three, could never reach. */
3028
3983
  const RESTART_BLOCKED_DETAIL = {
3984
+ desktop: "dsh-plugin-shop: the desktop profile is managed by the DeepSeek Harness desktop app, and dsh refuses to restart it from here; restart the app to apply the change",
3029
3985
  windows: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change",
3030
3986
  systemd: "dsh-plugin-shop: restart is disabled because this process is a systemd service — a restart would kill the takeover helper along with the unit, and the service would not come back. Set allowRestart: true in the shop row config to override.",
3031
3987
  "port-zero": "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
3032
3988
  };
3989
+ /** Why the shop changes nothing in the desktop profile, for every mutation
3990
+ * that spawns dsh's CLI: install, uninstall and self-update. dsh refuses
3991
+ * the profile before any of them would run (`isDesktopProfile`), and the
3992
+ * failure that surfaced instead read "pnpm failed in the profile". */
3993
+ const DESKTOP_PROFILE_DETAIL = "dsh-plugin-shop: the desktop profile is managed by the DeepSeek Harness desktop app, and dsh refuses to change it from the command line the shop runs; add and remove its plugins from the app instead";
3033
3994
  /** How many bytes a release tarball may be at the integrity check. The
3034
3995
  * registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
3035
3996
  * headroom, not a gate of its own. */
@@ -3087,6 +4048,25 @@ async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes =
3087
4048
  function ownDependencySpec(dependencies, name) {
3088
4049
  return Object.hasOwn(dependencies, name) ? dependencies[name] : void 0;
3089
4050
  }
4051
+ /**
4052
+ * Package names whose module this PROCESS may already hold in Node's module
4053
+ * cache (design 2026-09-26-market-borrowings §1.2).
4054
+ *
4055
+ * A hot mount can only deliver code the process has not imported yet. The hot
4056
+ * tree imports through the loader's `internal.import`, which is Node's own ESM
4057
+ * loader and caches a module by its URL, and an install rewrites a package's
4058
+ * files at the URL it already had (`nodeLinker: hoisted`). Once a name is in
4059
+ * here, a mount of it would re-run the cached module under whatever version is
4060
+ * now on disk.
4061
+ *
4062
+ * Module scope rather than a gateway field, because the cache it models is the
4063
+ * process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
4064
+ * must not forget what the process imported before it existed. Every gateway
4065
+ * seeds it with the profile's dependencies at construction (the boot
4066
+ * composition) and extends it with every name the hot path mounts. Nothing is
4067
+ * ever removed: an uninstall disposes the fiber, not the module record.
4068
+ */
4069
+ const importedThisProcess = /* @__PURE__ */ new Set();
3090
4070
  /** Remote-only service exposing the shop Remote methods of §7.3.
3091
4071
  *
3092
4072
  * @typert service shop */
@@ -3234,6 +4214,17 @@ let ShopGateway = (() => {
3234
4214
  });
3235
4215
  }
3236
4216
  options = __runInitializers(this, _instanceExtraInitializers);
4217
+ /** The context this gateway was built with: the shop's own fiber. Inside an
4218
+ * RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
4219
+ * rebound to the context that looked it up, so a call arriving over the
4220
+ * wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
4221
+ * 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
4222
+ * hot tree above all: registered from the gateway's context it was owned by
4223
+ * the gateway's fiber rather than the shop's, was listed under the gateway's
4224
+ * loader entry, and — the part a reader saw — sat where dsh's client
4225
+ * registry never composed it, so a hot-installed browser half needed a
4226
+ * restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
4227
+ home;
3237
4228
  /** The profile dsh installs into; discovered from this module's own
3238
4229
  * location when the caller does not supply one. */
3239
4230
  profile;
@@ -3269,6 +4260,8 @@ let ShopGateway = (() => {
3269
4260
  /** One pump for the whole gateway: batching is per profile and lives inside
3270
4261
  * it, so a second instance would race the first for the same store. */
3271
4262
  prefetcher;
4263
+ /** What this process may already have imported (see `importedThisProcess`). */
4264
+ imported;
3272
4265
  /** The install gate runs against the last loaded snapshot, never a fresh
3273
4266
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
3274
4267
  /** Finished install records retained, so a poll sees the true terminal
@@ -3288,22 +4281,18 @@ let ShopGateway = (() => {
3288
4281
  /** The user's own `registry=` from `~/.npmrc`, read at most once per
3289
4282
  * gateway. A wrapper distinguishes a genuine `null` from an unread value. */
3290
4283
  npmRegistryCache = null;
3291
- /** The incompatibility map already computed for `lastSnapshot`, keyed by
3292
- * that snapshot's own object identity. Design §3 asks for the verdict
3293
- * once per loaded snapshot, not once per RPC call: `loadCatalog` serves
3294
- * the same snapshot from its on-disk cache for minutes at a time, so
3295
- * without this, reopening the tab within that window would re-walk
3296
- * `node_modules` for every distinct peer name again — and Node only
3297
- * caches a SUCCESSFUL resolution, so a genuinely missing peer pays a full
3298
- * failed walk on every single call. Recomputed only when `catalog()`
3299
- * loads a snapshot that is not this exact object. */
3300
- incompatibleCache = null;
4284
+ /** The harness this process runs, read at most once per gateway: which dsh
4285
+ * is running cannot change while it runs, and the read imports a module. A
4286
+ * promise, so concurrent first calls share one read; `readRunningHarness`
4287
+ * never rejects, so there is no failure to retry. */
4288
+ harnessRead = null;
3301
4289
  /** Install records, running and finished; a poll finds one here or reports not found. */
3302
4290
  installs = /* @__PURE__ */ new Map();
3303
4291
  /** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
3304
4292
  installOrder = [];
3305
4293
  constructor(ctx, options = {}) {
3306
4294
  super(ctx, "shop");
4295
+ this.home = ctx;
3307
4296
  this.options = options;
3308
4297
  this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
3309
4298
  this.profileDir = options.profileDir;
@@ -3332,6 +4321,8 @@ let ShopGateway = (() => {
3332
4321
  this.ppid = options.ppid ?? process.ppid;
3333
4322
  this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
3334
4323
  this.prefetcher = options.prefetcher ?? createPrefetcher();
4324
+ this.imported = options.importedModules ?? importedThisProcess;
4325
+ for (const name of Object.keys(this.profileDependenciesOrNone() ?? {})) this.imported.add(name);
3335
4326
  try {
3336
4327
  cleanHotDir(this.profileDirResolved());
3337
4328
  } catch {}
@@ -3347,7 +4338,7 @@ let ShopGateway = (() => {
3347
4338
  try {
3348
4339
  createPeerVersionCheck({
3349
4340
  ranges: this.options.peerRanges ?? ownPeerRanges(),
3350
- resolve: this.options.resolvePeerVersion ?? nodeVersionResolver(pathToFileURL(join(this.profileDirResolved(), "cordis.yml")).href),
4341
+ resolve: this.options.resolvePeerVersion ?? this.peerVersionResolver(this.profileAnchor()),
3351
4342
  warn: (message) => {
3352
4343
  const logger = this.ctx.logger;
3353
4344
  if (logger === void 0) console.warn(message);
@@ -3379,6 +4370,64 @@ let ShopGateway = (() => {
3379
4370
  if (this.profileDir !== void 0) return this.profileDir;
3380
4371
  return discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).dir;
3381
4372
  }
4373
+ /** Where every "what can this installation's plugins import?" question
4374
+ * resolves from: the profile's own Loader root, which is where the harness
4375
+ * resolves plugins from. One definition for the peer presence check and the
4376
+ * load-time self-check, so neither can drift onto a different notion of
4377
+ * "the installation". NOT where the running harness is read from: a copy
4378
+ * of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
4379
+ * importable from here and is not what runs (`harness.ts`). Throws when no
4380
+ * profile directory can be discovered and none is given. */
4381
+ profileAnchor(profileDir = this.profileDirResolved()) {
4382
+ return pathToFileURL(join(profileDir, "cordis.yml")).href;
4383
+ }
4384
+ /** dsh's `pluginPackages` service, when the running harness provides one
4385
+ * (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
4386
+ * It is how such a harness serves its own packages to plugins, which no
4387
+ * walk of the profile's disk can see (design
4388
+ * 2026-09-01-harness-compatibility §11). */
4389
+ harnessPackages() {
4390
+ const service = this.ctx.get?.("pluginPackages");
4391
+ return typeof service?.packageOf === "function" ? service : null;
4392
+ }
4393
+ /** dsh's `pluginManager` service, when the running harness provides one
4394
+ * (0.1.7 and later), else null. Read on each use, like `pluginPackages`.
4395
+ * It is the only way back for a bundle its Plugins page deselected: the
4396
+ * 0.1.7 CLI does not select an installed package again on `add`, while
4397
+ * 0.1.5's does (both measured 2026-09-27). */
4398
+ pluginManager() {
4399
+ const service = this.ctx.get?.("pluginManager");
4400
+ return typeof service?.setBundleEnabled === "function" ? service : null;
4401
+ }
4402
+ /** "Does this installation provide `spec`?", asked from `anchor`: the
4403
+ * harness's own resolution in front of the walk where the harness has one,
4404
+ * the walk alone where it has not. */
4405
+ peerResolver(anchor) {
4406
+ const walk = nodeResolver(anchor);
4407
+ const packages = this.harnessPackages();
4408
+ return packages === null ? walk : harnessPackageResolver(walk, packages, anchor);
4409
+ }
4410
+ /** `peerResolver`'s twin for the versions the load-time self-check reads. */
4411
+ peerVersionResolver(anchor) {
4412
+ const walk = nodeVersionResolver(anchor);
4413
+ const packages = this.harnessPackages();
4414
+ return packages === null ? walk : harnessPackageVersionResolver(walk, packages, anchor);
4415
+ }
4416
+ /** `profileDirResolved`, or null when no profile directory can be
4417
+ * discovered — for the reads that answer "cannot tell" rather than fail. */
4418
+ profileDirOrNone() {
4419
+ try {
4420
+ return this.profileDirResolved();
4421
+ } catch {
4422
+ return null;
4423
+ }
4424
+ }
4425
+ /** The harness this process runs, read once (see `harnessRead`). */
4426
+ runningHarness() {
4427
+ const read = this.options.readHarness ?? readRunningHarness;
4428
+ this.harnessRead ??= read(this.restartScript);
4429
+ return this.harnessRead;
4430
+ }
3382
4431
  /** The inventory, through the wire remote: an envelope `{ ok, value }` or
3383
4432
  * `{ ok: false, error }`. Each row is re-validated before trust (same
3384
4433
  * discipline as the rowConfig cast). */
@@ -3403,8 +4452,9 @@ let ShopGateway = (() => {
3403
4452
  * down. A disable can land while the entry's init is still in flight: the
3404
4453
  * options flip but the finishing init brings the fiber up anyway, and a
3405
4454
  * plain re-update no-ops on the empty diff (dsh-market themes.ts:74-93).
3406
- * For an update swap this sequencing is mandatory, not defensive: two live
3407
- * instances of a service-providing plugin would collide at provision. */
4455
+ * The uninstall path is the one caller: an update used to swap its running
4456
+ * instance for a hot mount through here, and no longer mounts at all
4457
+ * (design 2026-09-26-market-borrowings §1). */
3408
4458
  /** The package's owned entry ids, or none when its bundle patch cannot be
3409
4459
  * read. For the paths where a live disable is an optimization and the
3410
4460
  * operation must succeed regardless; `setEnabled` reports the failure
@@ -3438,8 +4488,8 @@ let ShopGateway = (() => {
3438
4488
  * Through `ownDependencySpec` for the reason spelled out there: a bare index
3439
4489
  * read answers for `Object.prototype`, and `constructor` is a legal npm
3440
4490
  * name. The cost on this path is behavioural twice over — the gate would
3441
- * weigh a function as the installed spec, and a phantom `isUpdate` would run
3442
- * `liveDisableIds` against something absent.
4491
+ * weigh a function as the installed spec, and a phantom `isUpdate` would
4492
+ * refuse a hot mount to a package this process never imported.
3443
4493
  */
3444
4494
  installedSpecOf(name) {
3445
4495
  const dependencies = this.profileDependenciesOrNone();
@@ -3503,11 +4553,12 @@ let ShopGateway = (() => {
3503
4553
  }
3504
4554
  return allDown;
3505
4555
  }
3506
- /** Enable or disable one installed plugin, hot (§8): a disable writes the
3507
- * row to the user layer, an enable drops it again so the bundle default
3508
- * rules — the CLI's watchUserPatches applies either through HMR. The shop's
3509
- * own row and the framework's bundles are never toggleable: disabling the
3510
- * host chain would break HMR itself. */
4556
+ /** Enable or disable one installed plugin, hot (§8): the write sets the
4557
+ * `disabled` key of each owned entry's row in the user layer and changes
4558
+ * nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
4559
+ * watchUserPatches applies it through HMR. The shop's own row and the
4560
+ * framework's bundles are never toggleable: disabling the host chain would
4561
+ * break HMR itself. */
3511
4562
  async setEnabled(args) {
3512
4563
  if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
3513
4564
  ok: false,
@@ -3521,7 +4572,7 @@ let ShopGateway = (() => {
3521
4572
  };
3522
4573
  let owned;
3523
4574
  try {
3524
- owned = ownedEntryIds({
4575
+ owned = ownedEntries({
3525
4576
  profileDir,
3526
4577
  packageName: args.name
3527
4578
  });
@@ -3535,16 +4586,19 @@ let ShopGateway = (() => {
3535
4586
  ok: false,
3536
4587
  detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
3537
4588
  };
3538
- const ownedSet = new Set(owned);
4589
+ const selected = this.runningProfileBundles(profileDir);
4590
+ if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned);
4591
+ const ownedSet = new Set(owned.map((entry) => entry.id));
3539
4592
  if ((await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId)).length === 0) return {
3540
4593
  ok: false,
3541
4594
  detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
3542
4595
  };
3543
4596
  setUserLayerRows({
3544
4597
  profileDir,
3545
- rows: owned.map((id) => ({
4598
+ rows: owned.map(({ id, name }) => ({
3546
4599
  id,
3547
- disabled: !args.enabled
4600
+ disabled: !args.enabled,
4601
+ ...name !== void 0 ? { name } : {}
3548
4602
  }))
3549
4603
  });
3550
4604
  return {
@@ -3556,6 +4610,70 @@ let ShopGateway = (() => {
3556
4610
  })
3557
4611
  };
3558
4612
  }
4613
+ /**
4614
+ * `setEnabled` for an installed package whose bundle the profile does not
4615
+ * select.
4616
+ *
4617
+ * Switching it off writes the plugin-level rows and nothing else: none of
4618
+ * it runs, and the rows keep it off if dsh selects the bundle again.
4619
+ * Switching it on selects the bundle through dsh's own `pluginManager`, the
4620
+ * writer behind the switch that deselected it, and then clears the
4621
+ * plugin-level rows as an ordinary enable does. A harness without that
4622
+ * service gets the way back in words instead. `dsh plugin add <name>` is
4623
+ * not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
4624
+ * float a registry package to `latest`.
4625
+ */
4626
+ async setDeselectedEnabled(name, enabled, profileDir, owned) {
4627
+ const rows = owned.map(({ id, name: moduleName }) => ({
4628
+ id,
4629
+ disabled: !enabled,
4630
+ ...moduleName !== void 0 ? { name: moduleName } : {}
4631
+ }));
4632
+ if (!enabled) {
4633
+ setUserLayerRows({
4634
+ profileDir,
4635
+ rows
4636
+ });
4637
+ return {
4638
+ ok: true,
4639
+ activation: "live"
4640
+ };
4641
+ }
4642
+ const manager = this.pluginManager();
4643
+ if (manager === null) return {
4644
+ ok: false,
4645
+ detail: `dsh-plugin-shop: ${name} is installed but switched off at the bundle level: dsh.profile.bundles in ${join(profileDir, "package.json")} does not list it, so dsh composes none of it, and a restart will not change that. This dsh has no pluginManager service to select it again: add "${name}" back to that list and restart dsh, or uninstall it and install it again from the shop.`
4646
+ };
4647
+ let change;
4648
+ try {
4649
+ change = await manager.setBundleEnabled(name, true);
4650
+ } catch (error) {
4651
+ return {
4652
+ ok: false,
4653
+ detail: `dsh-plugin-shop: dsh could not select ${name} again: ${String(error)}`
4654
+ };
4655
+ }
4656
+ if (change.application === "failed" || change.application === "cancelled" || change.error !== void 0) return {
4657
+ ok: false,
4658
+ detail: `dsh-plugin-shop: dsh refused to select ${name} again (${typeof change.error?.code === "string" ? change.error.code : String(change.application)})${typeof change.error?.diagnostic === "string" ? `: ${change.error.diagnostic}` : ""}`
4659
+ };
4660
+ setUserLayerRows({
4661
+ profileDir,
4662
+ rows
4663
+ });
4664
+ if (change.application === "restart-required") return {
4665
+ ok: true,
4666
+ activation: "restart"
4667
+ };
4668
+ return {
4669
+ ok: true,
4670
+ activation: activationOf({
4671
+ hostLive: true,
4672
+ clientLive: true,
4673
+ hasClientHalf: this.packageHasClientHalf(name)
4674
+ })
4675
+ };
4676
+ }
3559
4677
  rowConfig() {
3560
4678
  if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
3561
4679
  catalogUrl: this.options.catalogUrl,
@@ -3635,11 +4753,16 @@ let ShopGateway = (() => {
3635
4753
  * static does. One ordered list, read by `restart()` before it commits and
3636
4754
  * by `version()` so the client can say the same thing up front.
3637
4755
  *
3638
- * The order is the order the refusals were written in and is load-bearing
3639
- * for the copy a reader sees: Windows first, because the platform check has
3640
- * no override and reporting the systemd one there sends a Windows user to
3641
- * set `allowRestart: true`, which this gate would still refuse.
4756
+ * The order is load-bearing for the copy a reader sees. The desktop
4757
+ * profile first: dsh itself refuses to launch it, so no platform,
4758
+ * supervisor or port could make a restart possible there. Then Windows,
4759
+ * because the platform check has no override and reporting the systemd one
4760
+ * there sends a Windows user to set `allowRestart: true`, which this gate
4761
+ * would still refuse.
3642
4762
  *
4763
+ * - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
4764
+ * by the Electron application"), so the relaunch would exit at once and
4765
+ * leave nothing on the port; the app restarts it.
3643
4766
  * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
3644
4767
  * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
3645
4768
  * committing would answer `ok: true`, exit this process, and leave nothing
@@ -3651,6 +4774,7 @@ let ShopGateway = (() => {
3651
4774
  * - `port-zero`: the OS hands the NEW process a fresh port the browser
3652
4775
  * cannot know, so a restart would strand the client on a dead origin. */
3653
4776
  staticRestartBlock() {
4777
+ if (isDesktopProfile(this.profile)) return "desktop";
3654
4778
  if (!this.restartPlatformSupported()) return "windows";
3655
4779
  if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return "systemd";
3656
4780
  const portIndex = this.restartArgv.indexOf("--port");
@@ -3664,20 +4788,31 @@ let ShopGateway = (() => {
3664
4788
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
3665
4789
  async catalog(args) {
3666
4790
  const { snapshot, stale } = await this.loadCatalogOnce(args?.refresh ?? false);
3667
- let incompatible;
3668
- if (this.incompatibleCache !== null && this.incompatibleCache.snapshot === snapshot) incompatible = this.incompatibleCache.map;
3669
- else {
3670
- try {
3671
- const resolve = this.options.resolvePeer ?? nodeResolver(pathToFileURL(join(this.profileDirResolved(), "cordis.yml")).href);
3672
- incompatible = incompatibilityMap(snapshot.entries, resolve);
3673
- } catch {
3674
- incompatible = {};
3675
- }
3676
- this.incompatibleCache = {
3677
- snapshot,
3678
- map: incompatible
3679
- };
4791
+ const harness = await this.runningHarness();
4792
+ const profileDir = this.profileDirOrNone();
4793
+ let incompatible = {};
4794
+ if (profileDir !== null) try {
4795
+ const resolve = this.options.resolvePeer ?? this.peerResolver(this.profileAnchor(profileDir));
4796
+ incompatible = incompatibilityMap(snapshot.entries, resolve);
4797
+ } catch {
4798
+ incompatible = {};
4799
+ }
4800
+ let incompatibleHarness;
4801
+ try {
4802
+ incompatibleHarness = compatibilityMap(snapshot.entries, {
4803
+ dshVersion: harness.dshVersion,
4804
+ profile: {
4805
+ name: this.profile,
4806
+ bundles: profileDir === null ? null : this.runningProfileBundles(profileDir)
4807
+ }
4808
+ }, harness.templates);
4809
+ } catch {
4810
+ incompatibleHarness = {};
3680
4811
  }
4812
+ for (const [key, peers] of Object.entries(this.refusedByHarness(snapshot.entries, harness, profileDir))) incompatibleHarness[key] = {
4813
+ ...incompatibleHarness[key],
4814
+ peers
4815
+ };
3681
4816
  return {
3682
4817
  schemaVersion: snapshot.schemaVersion,
3683
4818
  builtAt: snapshot.builtAt,
@@ -3686,15 +4821,56 @@ let ShopGateway = (() => {
3686
4821
  denied: snapshot.denied,
3687
4822
  notAShop: snapshot.notAShop ?? [],
3688
4823
  stars: snapshot.stars,
3689
- incompatible
4824
+ incompatible,
4825
+ incompatibleHarness
3690
4826
  };
3691
4827
  }
3692
4828
  /**
4829
+ * The installs the running dsh will refuse on their harness peers
4830
+ * (`peerVerdictsOf`), judged against the exemptions this profile holds —
4831
+ * read on every call like the other verdicts, because recording one is
4832
+ * exactly the event that must clear a card: the reader runs the command
4833
+ * the card shows, presses Refresh, and the install is allowed. Empty when
4834
+ * the running dsh has no such check (before 0.1.7) or no profile was
4835
+ * found, and when either read throws: a refusal nobody could establish is
4836
+ * never shown, least of all as a disabled button.
4837
+ */
4838
+ refusedByHarness(entries, harness, profileDir) {
4839
+ if (harness.peerCheck === null || profileDir === null) return {};
4840
+ try {
4841
+ return peerVerdictsOf(entries, harness.peerCheck, harness.peerCheck.exemptions(profileDir), this.profile);
4842
+ } catch {
4843
+ return {};
4844
+ }
4845
+ }
4846
+ /**
4847
+ * The bundles the profile at `profileDir` composes, or null when its
4848
+ * manifest cannot be read — which is no verdict on the profile half rather
4849
+ * than an accusation. `dsh.profile.bundles` is the same list
4850
+ * `discoverProfile` uses to recognize a profile directory, read through the
4851
+ * harness's own parser so a profile this shop was booted inside is read the
4852
+ * way dsh reads it.
4853
+ */
4854
+ runningProfileBundles(profileDir) {
4855
+ try {
4856
+ const bundles = readProfileManifest("dsh-plugin-shop", profileDir).dsh?.profile?.bundles;
4857
+ if (!Array.isArray(bundles) || !bundles.every((bundle) => typeof bundle === "string")) return null;
4858
+ return bundles;
4859
+ } catch {
4860
+ return null;
4861
+ }
4862
+ }
4863
+ /**
3693
4864
  * Install one cataloged version into the profile (§7.2). The rejection
3694
4865
  * paths run against this Host's snapshot before anything is spawned; only a
3695
4866
  * passing request reaches the executor.
3696
4867
  */
3697
4868
  async install(args) {
4869
+ if (isDesktopProfile(this.profile)) return {
4870
+ ok: false,
4871
+ code: "desktop-profile",
4872
+ detail: DESKTOP_PROFILE_DETAIL
4873
+ };
3698
4874
  const snapshot = await this.snapshotNow();
3699
4875
  const installedSpec = this.installedSpecOf(args.name);
3700
4876
  const verdict = validateInstall(snapshot, args, installedSpec);
@@ -3721,9 +4897,8 @@ let ShopGateway = (() => {
3721
4897
  };
3722
4898
  spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
3723
4899
  } else spec = `${args.name}@${args.version}`;
3724
- const isUpdate = installedSpec !== void 0;
3725
- const priorEntryIds = isUpdate ? this.ownedEntryIdsOrNone(args.name) : [];
3726
- const priorClientHalf = isUpdate && this.packageHasClientHalf(args.name);
4900
+ const alreadyImported = installedSpec !== void 0 || this.imported.has(args.name);
4901
+ const harness = await this.runningHarness();
3727
4902
  const running = startInstall({
3728
4903
  profile: this.profile,
3729
4904
  spec,
@@ -3736,29 +4911,37 @@ let ShopGateway = (() => {
3736
4911
  packageName: args.name,
3737
4912
  dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
3738
4913
  });
3739
- if (clash === null) return null;
3740
- return `dsh-plugin-shop: ${args.name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start. It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
4914
+ const undo = ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
4915
+ if (clash !== null) return `dsh-plugin-shop: ${args.name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start.` + undo;
4916
+ switch (patchDeclarationHazard(declaredBundlePatch({
4917
+ profileDir: this.profileDirResolved(),
4918
+ packageName: args.name
4919
+ }), harness.patchLists)) {
4920
+ case null: return null;
4921
+ case "list-unsupported": return `dsh-plugin-shop: ${args.name} lists its bundle patch as several files, which dsh reads from 0.1.7 on; this dsh${harness.dshVersion === null ? "" : ` (${harness.dshVersion})`} reads one and would not start with it installed.` + undo;
4922
+ case "malformed": return `dsh-plugin-shop: ${args.name} declares its bundle patch as neither a file path nor a list of them, which dsh refuses to load, so the profile would not start.` + undo;
4923
+ }
3741
4924
  },
3742
4925
  afterDone: async () => {
4926
+ if (alreadyImported) return {
4927
+ activation: "restart",
4928
+ restartReason: "already-loaded"
4929
+ };
3743
4930
  const hot = this.hot ?? {
3744
4931
  mount: hotMount,
3745
4932
  unmount: hotUnmount
3746
4933
  };
3747
- if (isUpdate) await this.liveEntriesDown(priorEntryIds);
3748
- const result = await hot.mount({ plugin: (plugin, config) => this.ctx.plugin(plugin, config) }, this.profileDirResolved(), args.name);
4934
+ this.imported.add(args.name);
4935
+ const result = await hot.mount({ plugin: (plugin, config) => this.home.plugin(plugin, config) }, this.profileDirResolved(), args.name);
3749
4936
  if (!result.ok) return {
3750
4937
  activation: "restart",
3751
4938
  ...result.reason !== null ? { restartReason: result.reason } : {}
3752
4939
  };
3753
- const activation = activationOf({
4940
+ return { activation: activationOf({
3754
4941
  hostLive: true,
3755
- clientLive: false,
3756
- hasClientHalf: priorClientHalf || this.packageHasClientHalf(args.name)
3757
- });
3758
- return activation === "restart" ? {
3759
- activation,
3760
- restartReason: "client-half"
3761
- } : { activation };
4942
+ clientLive: true,
4943
+ hasClientHalf: this.packageHasClientHalf(args.name)
4944
+ }) };
3762
4945
  }
3763
4946
  });
3764
4947
  if (entry.source === "github") {
@@ -3844,10 +5027,13 @@ let ShopGateway = (() => {
3844
5027
  for (const entry of await this.listInventory()) live.set(entry.entryId, entry.enabled);
3845
5028
  haveInventory = true;
3846
5029
  } catch {}
3847
- /** A package is enabled when every entry it owns and that is live is
3848
- * enabled. Keyed by entry id, never by module name — the entry a package
3849
- * inserts may mount a different package's module (see ownedEntryIds). */
5030
+ const selected = this.runningProfileBundles(this.profileDirResolved());
5031
+ /** A package is enabled when the profile selects its bundle and every
5032
+ * entry it owns and that is live is enabled. Keyed by entry id, never by
5033
+ * module name: the entry a package inserts may mount a different
5034
+ * package's module (see ownedEntryIds). */
3850
5035
  const enabledOf = (name) => {
5036
+ if (selected !== null && !selected.includes(name)) return false;
3851
5037
  if (!haveInventory) return true;
3852
5038
  let owned;
3853
5039
  try {
@@ -3914,6 +5100,10 @@ let ShopGateway = (() => {
3914
5100
  * dependencies the shop does not manage (the base bundle, the shop
3915
5101
  * itself). The same install records/polling serve the client. */
3916
5102
  async uninstall(args) {
5103
+ if (isDesktopProfile(this.profile)) return {
5104
+ ok: false,
5105
+ detail: DESKTOP_PROFILE_DETAIL
5106
+ };
3917
5107
  const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
3918
5108
  if (named.length === 0) return {
3919
5109
  ok: false,
@@ -3975,6 +5165,7 @@ let ShopGateway = (() => {
3975
5165
  ok: false,
3976
5166
  detail: RESTART_BLOCKED_DETAIL[blocked]
3977
5167
  };
5168
+ let logFile;
3978
5169
  try {
3979
5170
  const { cacheDir } = this.rowConfig();
3980
5171
  const { command, args } = restartCommand({
@@ -3984,11 +5175,12 @@ let ShopGateway = (() => {
3984
5175
  execArgv: process.execArgv,
3985
5176
  script: this.restartScript
3986
5177
  });
5178
+ logFile = join(cacheDir, "restart.log");
3987
5179
  startRestart({
3988
5180
  command,
3989
5181
  args,
3990
5182
  parentPid: this.restartParentPid,
3991
- logFile: join(cacheDir, "restart.log"),
5183
+ logFile,
3992
5184
  env: process.env
3993
5185
  });
3994
5186
  } catch (error) {
@@ -3998,27 +5190,57 @@ let ShopGateway = (() => {
3998
5190
  };
3999
5191
  }
4000
5192
  setTimeout(() => this.exit(0), this.restartExitDelayMs);
4001
- return { ok: true };
5193
+ return {
5194
+ ok: true,
5195
+ logFile
5196
+ };
4002
5197
  }
4003
5198
  /** The shop's own version and whether npm has a newer one (§7.3). The
4004
5199
  * check is advisory: a registry that cannot answer leaves `latest` null
4005
5200
  * and the client shows the version alone. `installed` is the RUNNING
4006
5201
  * version (own-version.ts), not the manifest's range spec. */
4007
5202
  async version() {
4008
- const installed = ownVersion();
5203
+ const installed = this.options.runningVersion ?? runningVersion();
4009
5204
  const latest = await this.latestVersion();
4010
5205
  return {
4011
5206
  installed,
4012
5207
  latest,
4013
5208
  outdated: latest !== null && lt(installed, latest),
4014
- restartBlocked: this.staticRestartBlock()
5209
+ restartBlocked: this.staticRestartBlock(),
5210
+ pendingVersion: this.pendingVersion(installed)
4015
5211
  };
4016
5212
  }
5213
+ /**
5214
+ * The version of the shop the profile has installed, when it is not the
5215
+ * one this process runs; null when they match or the copy cannot be read.
5216
+ *
5217
+ * Read from the profile's own copy — the directory `dsh plugin add` writes
5218
+ * and the next boot loads — rather than from the file beside this module.
5219
+ * For the hoisted install those are one directory, but a `link:` install
5220
+ * runs from its link target, which an update replaces in the profile and
5221
+ * never touches at the target, so reading beside the module would never see
5222
+ * the update land.
5223
+ *
5224
+ * Unreadable is null, never a throw: the version check is advisory, and a
5225
+ * missing field would tell the client this host predates it.
5226
+ */
5227
+ pendingVersion(running) {
5228
+ try {
5229
+ const manifest = JSON.parse(readFileSync(join(this.profileDirResolved(), "node_modules", "dsh-plugin-shop", "package.json"), "utf8"));
5230
+ return typeof manifest.version === "string" && manifest.version !== running ? manifest.version : null;
5231
+ } catch {
5232
+ return null;
5233
+ }
5234
+ }
4017
5235
  /** Update the shop itself to a published version (§7.3): the explicit pin
4018
5236
  * is the only install form that bypasses pnpm's release cooldown. The
4019
5237
  * version is re-validated as plain semver at the boundary — the spec
4020
5238
  * `dsh-plugin-shop@<version>` is built here, never from the wire. */
4021
5239
  async updateStart(args) {
5240
+ if (isDesktopProfile(this.profile)) return {
5241
+ ok: false,
5242
+ detail: DESKTOP_PROFILE_DETAIL
5243
+ };
4022
5244
  if (valid(args.version) === null) return {
4023
5245
  ok: false,
4024
5246
  detail: `dsh-plugin-shop: ${args.version} is not a valid version`