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

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
@@ -1229,6 +1509,35 @@ function chain(profile, task) {
1229
1509
  profileQueues.set(profile, next.catch(() => {}));
1230
1510
  return next;
1231
1511
  }
1512
+ /** Run `task` in `profile`'s queue, after every command already in it: the
1513
+ * queue `spawnPluginCli` uses, shared with the plugin manager's runner so the
1514
+ * two paths are never concurrent in one profile. `ahead` is how many commands
1515
+ * were queued or running when the slot was taken. */
1516
+ function inProfileQueue(profile, task) {
1517
+ return {
1518
+ ahead: enterQueue(profile),
1519
+ finished: chain(profile, task).finally(() => {
1520
+ leaveQueue(profile);
1521
+ })
1522
+ };
1523
+ }
1524
+ /** An install record's log: the newest lines within MAX_LOG_LINES and
1525
+ * MAX_LOG_BYTES, never dropping the newest one. */
1526
+ function createBoundedLog() {
1527
+ const lines = [];
1528
+ let bytes = 0;
1529
+ return {
1530
+ push(line) {
1531
+ lines.push(line);
1532
+ bytes += Buffer.byteLength(line);
1533
+ while ((lines.length > MAX_LOG_LINES || bytes > MAX_LOG_BYTES) && lines.length > 1) {
1534
+ const oldest = lines.shift();
1535
+ if (oldest !== void 0) bytes -= Buffer.byteLength(oldest);
1536
+ }
1537
+ },
1538
+ lines: () => [...lines]
1539
+ };
1540
+ }
1232
1541
  /**
1233
1542
  * Why a zero-exit install left `dsh.profile.bundles` without the entry.
1234
1543
  *
@@ -1335,6 +1644,94 @@ const FAILURE_LOG_NOISE = [
1335
1644
  /^\s+at /,
1336
1645
  /^<anonymous(?:_script)?>/
1337
1646
  ];
1647
+ /** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
1648
+ * `rejected`), and the exemption command its CLI prints after it, one per
1649
+ * refused package (`runPlugin`). The opener is exported for the plugin
1650
+ * manager's answer, whose output carries the same block. */
1651
+ const REFUSAL_OPENER = "dsh: installation rejected: ";
1652
+ const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
1653
+ const REFUSAL_FAILED = "dsh: plugin command failed";
1654
+ /** Where dsh's warning turns from the refused peers to its risk statement
1655
+ * and remedy (app-boot's `pluginCompatibilityWarning`); the detail states
1656
+ * the risk once and gives the command instead. */
1657
+ const REFUSAL_WARNING_TAIL = " Running it may cause crashes or data loss.";
1658
+ /**
1659
+ * dsh's own refusal of an install (0.1.7 on) as one detail, or null when the
1660
+ * log holds none. The install was dsh's to refuse, not pnpm's to fail: it
1661
+ * refuses before pnpm runs, when it can read the manifest — a registry spec,
1662
+ * which the catalog's peer verdict already predicts — and after pnpm
1663
+ * succeeded otherwise, restoring the profile. Reading that as a pnpm failure
1664
+ * sent the reader to `dsh plugin install`, which repairs nothing.
1665
+ *
1666
+ * The detail is what dsh refused, what it restored, and the command that
1667
+ * records its exact-version exemption, rebuilt from what dsh printed through
1668
+ * `allowVersionCommand` rather than repeated: dsh prints the INSTALLED
1669
+ * manifest's own name into it, which a tarball or git package chooses
1670
+ * itself, so only a name and versions dsh's exemption grammar accepts reach
1671
+ * a line meant for a terminal. The profile is the one this install ran in.
1672
+ * Anything dsh prints after `plugin command failed` — the diagnostics path,
1673
+ * and a hint about git build scripts dsh prints after every failed git
1674
+ * install — says nothing about this refusal.
1675
+ */
1676
+ function refusalDetail(profile, usable) {
1677
+ const start = usable.findIndex((line) => line.startsWith(REFUSAL_OPENER));
1678
+ if (start === -1) return null;
1679
+ const refusedAt = usable.slice(start);
1680
+ const failedAt = refusedAt.findIndex((line) => line.startsWith(REFUSAL_FAILED));
1681
+ const block = failedAt === -1 ? refusedAt : refusedAt.slice(0, failedAt);
1682
+ const warnings = [];
1683
+ const commands = [];
1684
+ let restoration;
1685
+ for (const [index, line] of block.entries()) {
1686
+ const command = REFUSAL_COMMAND.exec(line);
1687
+ if (command !== null) {
1688
+ const [, name = "", version = "", runtimeVersion = ""] = command;
1689
+ const rebuilt = allowVersionCommand(profile, {
1690
+ name,
1691
+ version,
1692
+ runtimeVersion
1693
+ });
1694
+ if (rebuilt !== null) commands.push(rebuilt);
1695
+ } else if (index === 0) warnings.push(line.slice(28));
1696
+ else if (line.startsWith("dsh: ")) restoration ??= line.slice(5);
1697
+ else if (restoration === void 0) warnings.push(line);
1698
+ }
1699
+ const parts = [`dsh refused the install: ${warnings.map((warning) => {
1700
+ const tail = warning.indexOf(REFUSAL_WARNING_TAIL);
1701
+ return tail === -1 ? warning : warning.slice(0, tail);
1702
+ }).join(" ")}`];
1703
+ if (restoration !== void 0) parts.push(`${restoration.charAt(0).toUpperCase()}${restoration.slice(1)}`);
1704
+ 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.`);
1705
+ return parts.join(" ");
1706
+ }
1707
+ /** What Rust's allocator prints before it aborts, which is how pnpm 12's
1708
+ * native binary dies when it cannot get the memory it asked for. Anchored at
1709
+ * both ends: node's own heap exhaustion ("Allocation failed - JavaScript heap
1710
+ * out of memory") is a different crash, from pnpm 11 or dsh itself, and the
1711
+ * pnpm 12 remedy below would be wrong for it. */
1712
+ const NATIVE_ALLOCATION_ABORT = /^memory allocation of \d+ bytes failed$/;
1713
+ /**
1714
+ * pnpm 12 aborting out of memory as one detail, or null when the log holds no
1715
+ * such abort.
1716
+ *
1717
+ * The known cause is pnpm/pnpm#15362, fixed in pnpm 12.7.0: the native peer
1718
+ * check intersected the ranges of a missing peer, doubling the intersection
1719
+ * for every package that wanted it. dsh writes `autoInstallPeers: false` into
1720
+ * every profile, so every `@deepseek-ai/dsh*` peer stays missing and plugins
1721
+ * that share one trip it. Reported on Windows with 8 GB, reproduced on Linux
1722
+ * through the real CLI of dsh 0.1.5-rc.3 and 0.1.7-rc.2 (2026-09-27). The
1723
+ * install aborted, so running it again on the same profile aborts again:
1724
+ * the detail names the way out instead of the usual recovery hint.
1725
+ *
1726
+ * Not every such abort is that bug (pnpm/pnpm#15867 is another one, with the
1727
+ * same words), so the detail says what to do if the reader's pnpm already has
1728
+ * the fix.
1729
+ */
1730
+ function nativeAllocationAbortDetail(usable) {
1731
+ const abort = usable.find((line) => NATIVE_ALLOCATION_ABORT.test(line.trim()));
1732
+ if (abort === void 0) return null;
1733
+ 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.`;
1734
+ }
1338
1735
  /**
1339
1736
  * The one log line worth putting in front of the user, plus the recovery hint.
1340
1737
  *
@@ -1349,9 +1746,12 @@ const FAILURE_LOG_NOISE = [
1349
1746
  * Scanning from the end: a pnpm error code first, then any thrown error, then
1350
1747
  * the last line that is not noise.
1351
1748
  */
1352
- function installFailureDetail(profile, log) {
1353
- const hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`;
1749
+ function installFailureDetail(profile, log, hint = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install`) {
1354
1750
  const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
1751
+ const refusal = refusalDetail(profile, usable);
1752
+ if (refusal !== null) return refusal;
1753
+ const abort = nativeAllocationAbortDetail(usable);
1754
+ if (abort !== null) return abort;
1355
1755
  const reversed = [...usable].reverse();
1356
1756
  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
1757
  if (pick === void 0) return `${hint}.`;
@@ -1454,6 +1854,23 @@ function dshScript() {
1454
1854
  * the spawned command line is the one we add ourselves, after this gate. */
1455
1855
  const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
1456
1856
  /**
1857
+ * The operand gate, one function for every caller: it refuses, by throwing,
1858
+ * an operand the dsh CLI would parse as a flag, and one carrying the shell
1859
+ * punctuation `UNSAFE_TARGET` names. A legitimate operand (a catalog name for
1860
+ * a removal; a `name@version`, `github:` or tarball spec for an install)
1861
+ * never begins with `-`, so the first refusal cannot reject a real install or
1862
+ * uninstall, and failing loudly beats letting the CLI reinterpret an operand
1863
+ * as an option. `spawnPluginCli` runs it before it spawns anything or takes a
1864
+ * queue slot; the gateway's plugin manager path runs it before a spec reaches
1865
+ * `installBundle` or the download phase, whose pump relies on it (see
1866
+ * `requestDownloadPhase`). Returns the operand, known to be present.
1867
+ */
1868
+ function gateOperand(target) {
1869
+ if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
1870
+ if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
1871
+ return target;
1872
+ }
1873
+ /**
1457
1874
  * The operand as the downstream dsh must receive it for pnpm to see it whole.
1458
1875
  *
1459
1876
  * dsh spawns pnpm with `shell: process.platform === 'win32'`
@@ -1491,6 +1908,32 @@ function shellSafeTarget(target, platform) {
1491
1908
  if (platform !== "win32" || !target.includes("&")) return target;
1492
1909
  return `"${target}"`;
1493
1910
  }
1911
+ const DOWNLOAD_PHASE_FAILED_PREFIX = "dsh-plugin-shop: the download phase could not start — ";
1912
+ const DOWNLOAD_PHASE_NO_PNPM = "dsh-plugin-shop: no download phase — pnpm not found on PATH";
1913
+ const DOWNLOAD_PHASE_UNSUPPORTED = "dsh-plugin-shop: no download phase for this spec form; the install fetches it directly";
1914
+ /**
1915
+ * Ask the pump to warm `spec` while an operation waits its turn, and say in
1916
+ * that operation's log why there is no download phase when there is none.
1917
+ * Shared by the CLI executor and the plugin manager runner.
1918
+ */
1919
+ function requestDownloadPhase(options) {
1920
+ const { prefetcher, profile, spec, env, log } = options;
1921
+ let prefetch;
1922
+ try {
1923
+ prefetch = prefetcher.request({
1924
+ profile,
1925
+ spec,
1926
+ cwd: resolveProfileDir(profile, env?.DSH_HOME),
1927
+ env,
1928
+ log
1929
+ });
1930
+ } catch (error) {
1931
+ log(`${DOWNLOAD_PHASE_FAILED_PREFIX}${error.message}`);
1932
+ return null;
1933
+ }
1934
+ if (!prefetch.started) log(prefetch.reason === "no-pnpm" ? DOWNLOAD_PHASE_NO_PNPM : DOWNLOAD_PHASE_UNSUPPORTED);
1935
+ return prefetch;
1936
+ }
1494
1937
  /** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
1495
1938
  * Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
1496
1939
  * (§10). The shop never passes build-script flags: `allowBuilds` stays the
@@ -1507,14 +1950,11 @@ function shellSafeTarget(target, platform) {
1507
1950
  * package IS installed; it reports `done` with the restart fallback. */
1508
1951
  function spawnPluginCli(options) {
1509
1952
  const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, prefetcher, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
1510
- const target = argv[1];
1511
- if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
1512
- if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
1953
+ const target = gateOperand(argv[1]);
1513
1954
  const spawnArgv = [...argv];
1514
1955
  spawnArgv[1] = shellSafeTarget(target, platform);
1515
1956
  const installId = randomUUID();
1516
- const log = [];
1517
- let logBytes = 0;
1957
+ const log = createBoundedLog();
1518
1958
  const ahead = enterQueue(profile);
1519
1959
  let state = "running";
1520
1960
  let activationOnDone = "restart";
@@ -1522,7 +1962,7 @@ function spawnPluginCli(options) {
1522
1962
  let detail;
1523
1963
  const status = () => ({
1524
1964
  state,
1525
- log: [...log],
1965
+ log: log.lines(),
1526
1966
  ...state === "done" ? {
1527
1967
  activation: activationOnDone,
1528
1968
  ...restartReason !== void 0 ? { restartReason } : {}
@@ -1532,11 +1972,6 @@ function spawnPluginCli(options) {
1532
1972
  const append = (line) => {
1533
1973
  if (isTerminalInstallState(state)) return;
1534
1974
  log.push(line);
1535
- logBytes += Buffer.byteLength(line);
1536
- while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
1537
- const oldest = log.shift();
1538
- if (oldest !== void 0) logBytes -= Buffer.byteLength(oldest);
1539
- }
1540
1975
  onStatus?.(status());
1541
1976
  };
1542
1977
  const failToStart = (error) => {
@@ -1546,19 +1981,14 @@ function spawnPluginCli(options) {
1546
1981
  return status();
1547
1982
  };
1548
1983
  let prefetch = null;
1549
- if (ahead > 0 && prefetcher !== void 0) try {
1550
- prefetch = prefetcher.request({
1551
- profile,
1552
- spec: target,
1553
- cwd: resolveProfileDir(profile, env?.DSH_HOME),
1554
- env,
1555
- log: append
1556
- });
1557
- } catch (error) {
1558
- append(`dsh-plugin-shop: the download phase could not start — ${error.message}`);
1559
- }
1984
+ if (ahead > 0 && prefetcher !== void 0) prefetch = requestDownloadPhase({
1985
+ prefetcher,
1986
+ profile,
1987
+ spec: target,
1988
+ env,
1989
+ log: append
1990
+ });
1560
1991
  if (prefetch?.started === true) state = "downloading";
1561
- else if (prefetch !== null) append(prefetch.reason === "no-pnpm" ? "dsh-plugin-shop: no download phase — pnpm not found on PATH" : "dsh-plugin-shop: no download phase for this spec form; the install fetches it directly");
1562
1992
  const finished = chain(profile, () => new Promise((resolve) => {
1563
1993
  state = "running";
1564
1994
  onStatus?.(status());
@@ -1627,7 +2057,7 @@ function spawnPluginCli(options) {
1627
2057
  } else state = "done";
1628
2058
  } else {
1629
2059
  state = "failed";
1630
- detail = installFailureDetail(profile, log);
2060
+ detail = installFailureDetail(profile, log.lines());
1631
2061
  }
1632
2062
  onStatus?.(status());
1633
2063
  resolve(status());
@@ -1753,6 +2183,47 @@ function startUninstall(options) {
1753
2183
  });
1754
2184
  }
1755
2185
  //#endregion
2186
+ //#region src/host/bundle-patch.ts
2187
+ /**
2188
+ * The patch files a bundle declares, read the way dsh reads them (design
2189
+ * 2026-09-26-dsh-017-readiness, B4).
2190
+ *
2191
+ * dsh 0.1.7's app-boot (`bundlePatchFiles`) accepts `dsh.bundle.patch` as one
2192
+ * package-relative file or as a list of them, applied in order, and throws on
2193
+ * anything else. dsh 0.1.5 reads only the string form: it joins the
2194
+ * declaration onto the package directory, so a list makes the profile fail to
2195
+ * start. What the RUNNING dsh accepts is `RunningHarness.patchLists`; this
2196
+ * reads the declaration itself, for the shop's own readers of a package's
2197
+ * patch — the entries it owns, the rows a hot mount replicates.
2198
+ */
2199
+ /**
2200
+ * The declared patch files in application order: one for a string, the
2201
+ * listed ones for a list of strings. Null for a declaration dsh refuses —
2202
+ * neither a path nor a list of paths — which is distinct from an absent one:
2203
+ * only absence means the conventional `cordis.patch.yml`, and a caller that
2204
+ * fell back to it for a refused declaration would act on a patch dsh never
2205
+ * applies. Pure, and the list is a copy.
2206
+ */
2207
+ function bundlePatchFiles(declared) {
2208
+ if (typeof declared === "string") return [declared];
2209
+ if (Array.isArray(declared) && declared.every((file) => typeof file === "string")) return [...declared];
2210
+ return null;
2211
+ }
2212
+ /**
2213
+ * Whether an installed package's `dsh.bundle.patch` would stop the profile
2214
+ * from starting at the next boot, given whether the running dsh reads a list
2215
+ * (`RunningHarness.patchLists`). An absent declaration is no hazard: dsh adds
2216
+ * a package to the profile's bundles only when it declares a patch, so
2217
+ * nothing reads the field. An unknown harness (null) forms no verdict on a
2218
+ * list — the rule for every harness read — but a malformed declaration is
2219
+ * refused by every dsh, so it is named whatever is known. Pure.
2220
+ */
2221
+ function patchDeclarationHazard(declared, patchLists) {
2222
+ if (declared === void 0) return null;
2223
+ if (bundlePatchFiles(declared) === null) return "malformed";
2224
+ return Array.isArray(declared) && patchLists === false ? "list-unsupported" : null;
2225
+ }
2226
+ //#endregion
1756
2227
  //#region src/host/hot.ts
1757
2228
  /**
1758
2229
  * Restart-free installs: mount a freshly installed plugin into the running
@@ -1878,15 +2349,14 @@ function nextHotNumber(fs, dir) {
1878
2349
  }
1879
2350
  return max + 1;
1880
2351
  }
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) {
2352
+ /** The installed package's own `dsh.bundle.patch`, exactly as declared (the
2353
+ * Include input), or undefined when the package, its manifest or the field
2354
+ * is absent. */
2355
+ function declaredPatch(fs, packageDir) {
1885
2356
  try {
1886
- const patch = JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
1887
- return typeof patch === "string" ? { patch } : null;
2357
+ return JSON.parse(fs.read(join(packageDir, "package.json"))).dsh?.bundle?.patch;
1888
2358
  } catch {
1889
- return null;
2359
+ return;
1890
2360
  }
1891
2361
  }
1892
2362
  /** Load the Include class through a computed dynamic import, or null when
@@ -1924,33 +2394,45 @@ function suppressWrite(treeClass) {
1924
2394
  async function hotMount(ctx, profileDir, packageName, deps = {}) {
1925
2395
  const { fs = nodeHotFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
1926
2396
  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`);
2397
+ const declared = declaredPatch(fs, packageDir);
2398
+ const files = declared === void 0 ? ["cordis.patch.yml"] : bundlePatchFiles(declared);
2399
+ if (files === null || files.length === 0) {
2400
+ 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
2401
  return {
1943
2402
  ok: false,
1944
2403
  reason: "no-patch"
1945
2404
  };
1946
2405
  }
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
- };
2406
+ const rows = [];
2407
+ for (const file of files) {
2408
+ const patchFile = resolve(packageDir, file);
2409
+ const inside = relative(packageDir, patchFile);
2410
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
2411
+ ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
2412
+ return {
2413
+ ok: false,
2414
+ reason: "no-patch"
2415
+ };
2416
+ }
2417
+ let patchText;
2418
+ try {
2419
+ patchText = fs.read(patchFile);
2420
+ } catch {
2421
+ ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
2422
+ return {
2423
+ ok: false,
2424
+ reason: "no-patch"
2425
+ };
2426
+ }
2427
+ const parsed = parseSimplePatch(patchText);
2428
+ if (parsed === null) {
2429
+ ctx.logger?.warn(`hot-mount ${packageName}: patch has rows that cannot be hot-mounted — restart will activate it`);
2430
+ return {
2431
+ ok: false,
2432
+ reason: "not-simple"
2433
+ };
2434
+ }
2435
+ rows.push(...parsed);
1954
2436
  }
1955
2437
  let treeClass = deps.hotTreeClass;
1956
2438
  if (treeClass === void 0) treeClass = await loadHotTreeClass();
@@ -2061,18 +2543,21 @@ function cleanHotDir(profileDir) {
2061
2543
  * - A change to the BOOT COMPOSITION — a toggle, an uninstall — moves an
2062
2544
  * entry the registry already enumerates, and the served graph follows it
2063
2545
  * 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.
2546
+ * - A HOT MOUNT — an install — adds entries under the shop's own loader
2547
+ * entry, which the registry enumerates like any other: across a reload
2548
+ * following the mount the served graph gains the package and the page runs
2549
+ * its browser half (measured 2026-09-26 on 0.1.5-rc.3 and 0.1.7-rc.2, in
2550
+ * `web-full-flow.e2e.ts`). `true`.
2071
2551
  *
2072
2552
  * The asymmetry that decides every unknown still runs the same way: offering
2073
2553
  * 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.
2554
+ * that was needed is the defect this module exists to fix. Which step a hot
2555
+ * mount needs has been measured twice. On 2026-09-14 the graph came back
2556
+ * byte-identical across the reload, and installs moved to `restart` — but the
2557
+ * shop then registered the tree from whichever context made the RPC call, the
2558
+ * typert gateway's, and the registry never composed a tree hung there. Once
2559
+ * the tree hung off the shop's own entry (2026-09-26), the same measurement
2560
+ * found the package in the graph, and installs moved back to `reload`.
2076
2561
  */
2077
2562
  function activationOf(input) {
2078
2563
  if (!input.hostLive) return "restart";
@@ -2294,18 +2779,14 @@ function writeRepoPins(fs, path, pins) {
2294
2779
  //#endregion
2295
2780
  //#region src/host/profile.ts
2296
2781
  /** 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]);
2782
+ /** The loader's `!!js` expression scalar, for the document edit: read as its
2783
+ * source text and written back under its own tag, never evaluated — the
2784
+ * loader evaluates it at entry activation. The same declaration the harness's
2785
+ * own writer of this file uses (`@deepseek-ai/dsh-plugin-manager` 0.1.7). */
2786
+ const USER_LAYER_TAGS = [{
2787
+ tag: "tag:yaml.org,2002:js",
2788
+ resolve: (source) => source
2789
+ }];
2309
2790
  /**
2310
2791
  * Find the profile directory that owns `startPath`.
2311
2792
  *
@@ -2364,22 +2845,78 @@ function realpathNearestExisting(startPath) {
2364
2845
  * {@link ownedEntryIds}), and they toggle together: writing them one at a
2365
2846
  * time would read-modify-write the file once per entry, so a crash between
2366
2847
  * two writes would leave the package half disabled.
2848
+ *
2849
+ * The edit changes one key and nothing else (design
2850
+ * 2026-09-26-market-borrowings §2). The layer is validated by the harness's
2851
+ * own parser first, so one it cannot load still throws and is never
2852
+ * rewritten; it is then edited as a YAML document, which keeps every comment,
2853
+ * every other row, every other key and every `!!js` scalar as the user wrote
2854
+ * them. The whole-file rewrite this replaced dropped each toggled id's row —
2855
+ * and with it a `config:` override the user had put there — and dumped the
2856
+ * list back through a library that models no comments, so the first toggle
2857
+ * erased the header dsh writes into every new profile.
2858
+ *
2859
+ * For each entry the row written is the LAST one carrying its id, when the
2860
+ * harness is sure to apply it: applyEntryPatches applies rows in order and
2861
+ * each key replaces the entry's value, so the last row that sets `disabled`
2862
+ * decides it. A row with `insert` is an insertion into a group, never an
2863
+ * override; a row naming another module is skipped by the harness; a named
2864
+ * row whose module the caller did not say cannot be judged. In each of those
2865
+ * cases a row is appended instead, which the harness applies last whatever
2866
+ * came before it — so appending is never wrong, only less tidy.
2867
+ *
2868
+ * An enable writes `disabled: false` rather than deleting the row, which is
2869
+ * what lost a user's row or the comment above it; it is also the harness's
2870
+ * own convention for this file from 0.1.7. The file keeps its permission bits
2871
+ * across the rename (a new one is created owner-only, as the harness's writer
2872
+ * does), because a config override can hold a credential.
2367
2873
  */
2368
2874
  function setUserLayerRows(options) {
2369
2875
  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
- }))];
2876
+ loadOptionalPatches("dsh-plugin-shop", file);
2877
+ let text = "[]\n";
2878
+ let mode = 384;
2879
+ if (existsSync(file)) {
2880
+ text = readFileSync(file, "utf8");
2881
+ mode = statSync(file).mode & 511;
2882
+ }
2883
+ const document = parseDocument(text, { customTags: USER_LAYER_TAGS });
2884
+ const parseError = document.errors[0];
2885
+ if (parseError !== void 0) throw parseError;
2886
+ const list = document.contents;
2887
+ if (!isSeq(list)) throw new Error(`dsh-plugin-shop: ${file} is not a YAML list of patch rows`);
2888
+ for (const row of options.rows) {
2889
+ const target = lastRowFor(list.items, row.id);
2890
+ if (target !== null && appliesTo(target, row.name)) {
2891
+ target.set("disabled", row.disabled);
2892
+ continue;
2893
+ }
2894
+ if (list.items.length === 0) list.flow = false;
2895
+ document.add({
2896
+ id: row.id,
2897
+ disabled: row.disabled
2898
+ });
2899
+ }
2376
2900
  const tmp = `${file}.tmp`;
2377
- writeFileSync(tmp, dump(next, {
2378
- noRefs: true,
2379
- schema: USER_LAYER_SCHEMA
2380
- }));
2901
+ writeFileSync(tmp, document.toString({ lineWidth: 0 }), { mode });
2902
+ chmodSync(tmp, mode);
2381
2903
  renameSync(tmp, file);
2382
2904
  }
2905
+ /** The last override row carrying `id`, or null when there is none. A row
2906
+ * with `insert` is an insertion into group `id`, not an override of it. */
2907
+ function lastRowFor(items, id) {
2908
+ for (let index = items.length - 1; index >= 0; index--) {
2909
+ const item = items[index];
2910
+ if (isMap(item) && !item.has("insert") && item.get("id") === id) return item;
2911
+ }
2912
+ return null;
2913
+ }
2914
+ /** Whether the harness is sure to apply `row` to the entry mounting
2915
+ * `moduleName`: a row with no `name`, or one naming that very module. */
2916
+ function appliesTo(row, moduleName) {
2917
+ if (!row.has("name")) return true;
2918
+ return moduleName !== void 0 && row.get("name") === moduleName;
2919
+ }
2383
2920
  /**
2384
2921
  * The loader entry ids one installed package contributes to the tree.
2385
2922
  *
@@ -2402,41 +2939,73 @@ function setUserLayerRows(options) {
2402
2939
  * which only the inventory can answer.
2403
2940
  */
2404
2941
  function ownedEntryIds(options) {
2942
+ return ownedEntries(options).map((entry) => entry.id);
2943
+ }
2944
+ /**
2945
+ * {@link ownedEntryIds} with the module each entry mounts beside its id. The
2946
+ * name is what the harness judges a NAMED user-layer row by — a row naming a
2947
+ * different module is skipped (applyEntryPatches) — so it is what lets the
2948
+ * toggle write a user's own named row rather than append one beside it. It is
2949
+ * read through the harness's parser, so a relative name arrives anchored to a
2950
+ * file URL exactly as the composed entry carries it; an `!!js` name has no
2951
+ * plain value and reads as undefined. Throws where `ownedEntryIds` does.
2952
+ */
2953
+ function ownedEntries(options) {
2405
2954
  const packageDir = join(options.profileDir, "node_modules", ...options.packageName.split("/"));
2406
2955
  const manifestPath = join(packageDir, "package.json");
2407
2956
  if (!existsSync(manifestPath)) return [];
2408
- let patchRelative;
2957
+ let declared;
2409
2958
  try {
2410
- patchRelative = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2959
+ declared = JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2411
2960
  } catch (error) {
2412
2961
  throw new Error(`dsh-plugin-shop: failed to read ${manifestPath}: ${String(error)}`);
2413
2962
  }
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)];
2963
+ const files = bundlePatchFiles(declared);
2964
+ if (files === null) return [];
2965
+ const entries = [];
2966
+ for (const file of files) {
2967
+ const patchFile = resolve(packageDir, file);
2968
+ const inside = relative(packageDir, patchFile);
2969
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`dsh-plugin-shop: ${options.packageName} declares a bundle patch outside its own directory: ${file}`);
2970
+ collectInsertedEntries(loadOptionalPatches("dsh-plugin-shop", patchFile) ?? [], entries);
2971
+ }
2972
+ const seen = /* @__PURE__ */ new Set();
2973
+ return entries.filter((entry) => !seen.has(entry.id) && seen.add(entry.id) !== void 0);
2974
+ }
2975
+ /**
2976
+ * An installed package's own `dsh.bundle.patch`, exactly as declared, or
2977
+ * undefined when the package, its manifest or the field is absent — or the
2978
+ * manifest cannot be read, which no post-install check can judge either.
2979
+ */
2980
+ function declaredBundlePatch(options) {
2981
+ const manifestPath = join(options.profileDir, "node_modules", ...options.packageName.split("/"), "package.json");
2982
+ try {
2983
+ return JSON.parse(readFileSync(manifestPath, "utf8")).dsh?.bundle?.patch;
2984
+ } catch {
2985
+ return;
2986
+ }
2421
2987
  }
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
2988
+ /** Walk a patch list, appending every INSERTED entry. A patch row without
2989
+ * `insert` targets an entry someone else composed — the loader's
2424
2990
  * applyEntryPatches looks it up and skips it when absent, so it creates
2425
2991
  * nothing and owns nothing. An inserted GROUP owns its children, which the
2426
2992
  * loader reads from the group's own `config` array. */
2427
- function collectInsertedIds(rows, into) {
2993
+ function collectInsertedEntries(rows, into) {
2428
2994
  for (const row of rows) {
2429
2995
  if (row === null || typeof row !== "object") continue;
2430
2996
  const inserted = row.insert;
2431
- if (Array.isArray(inserted)) collectEntryIds(inserted, into);
2997
+ if (Array.isArray(inserted)) collectEntries(inserted, into);
2432
2998
  }
2433
2999
  }
2434
- function collectEntryIds(entries, into) {
3000
+ function collectEntries(entries, into) {
2435
3001
  for (const entry of entries) {
2436
3002
  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);
3003
+ const { id, name, group, config } = entry;
3004
+ if (typeof id === "string") into.push({
3005
+ id,
3006
+ name: typeof name === "string" ? name : void 0
3007
+ });
3008
+ if (group === true && Array.isArray(config)) collectEntries(config, into);
2440
3009
  }
2441
3010
  }
2442
3011
  /**
@@ -2820,25 +3389,240 @@ function createPrefetcher(options = {}) {
2820
3389
  * not provide (design 2026-09-01-harness-compatibility), and — for the
2821
3390
  * shop's OWN declared peers — which ones it provides at a version outside
2822
3391
  * the declared range. */
3392
+ const NODE_LOOKUP_FS = {
3393
+ stat: (path) => statSync(path),
3394
+ readFile: (path) => readFileSync(path, "utf8")
3395
+ };
3396
+ /**
3397
+ * One segment of a bare package name, about to be spliced into a path.
3398
+ *
3399
+ * A leading `.` refuses the segment whole: `.` and `..` would walk the path
3400
+ * out of `node_modules`, and `.bin` or `.pnpm` are directories an install
3401
+ * carries that no package can be. The ESM resolver agrees as far as it goes:
3402
+ * it refuses an unscoped name that starts with `.`, and any name holding `%`
3403
+ * or `\`, with ERR_INVALID_MODULE_SPECIFIER (measured on Node 26.6.0 with
3404
+ * `.bin`, `a%2e`, `a\b`). `\` is also a separator on Windows, `:` names a
3405
+ * drive, an NTFS stream or a URL scheme (`node:fs`, `file:`), and NUL is a
3406
+ * byte no path may carry.
3407
+ */
3408
+ function isNameSegment(segment) {
3409
+ return segment.length > 0 && !segment.startsWith(".") && !/[\\:%\0]/.test(segment);
3410
+ }
3411
+ /**
3412
+ * Whether `spec` is a bare package name — `name`, or `@scope/name` — and so
3413
+ * safe to look up. Peer names are catalog input, and hostile; this check is
3414
+ * what keeps one from becoming a path. The lookup this replaced resolved
3415
+ * `'../x/package.json'` relative to the profile and an absolute name as
3416
+ * itself, so a peer name could point it at any path on the reader's disk.
3417
+ */
3418
+ function isBarePackageName(spec) {
3419
+ const segments = spec.split("/");
3420
+ const [first, second] = segments;
3421
+ if (first === void 0) return false;
3422
+ if (!first.startsWith("@")) return segments.length === 1 && isNameSegment(first);
3423
+ return segments.length === 2 && second !== void 0 && isNameSegment(first.slice(1)) && isNameSegment(second);
3424
+ }
3425
+ /** Whether `path` stats as a directory. Every stat failure answers false —
3426
+ * see `findPackageDir` for why that is Node's rule and not a shortcut. */
3427
+ function isDirectoryAt(fs, path) {
3428
+ try {
3429
+ return fs.stat(path).isDirectory();
3430
+ } catch {
3431
+ return false;
3432
+ }
3433
+ }
3434
+ /** Whether `path` stats as a regular file. Every stat failure answers false,
3435
+ * for the reason `isDirectoryAt` gives. */
3436
+ function isFileAt(fs, path) {
3437
+ try {
3438
+ return fs.stat(path).isFile();
3439
+ } catch {
3440
+ return false;
3441
+ }
3442
+ }
3443
+ /**
3444
+ * The directory the ESM resolver MATCHES for `spec` from `fromDir`, or null
3445
+ * when the walk reaches the filesystem root without one: for each ancestor
3446
+ * `dir`, the candidate `dir/node_modules/<spec>`, and the first that stats as
3447
+ * a DIRECTORY is the match. A candidate that is anything else — absent, a
3448
+ * file, or a path whose stat fails for ANY reason — is "not here", and the
3449
+ * walk moves on to the next ancestor.
3450
+ *
3451
+ * That is Node's own rule, copied from its reader rather than from the spec
3452
+ * prose. Node 26.6.0 `internal/modules/package_json_reader.js`
3453
+ * (`getPackageJSONURL`) does
3454
+ * `const stat = internalModuleStat(...); if (stat !== 1) { ...; continue }`:
3455
+ * 1 is "directory", and a failed stat is a negative errno, so EACCES and
3456
+ * ELOOP keep walking exactly like ENOENT. Measured on 26.6.0: a self-link
3457
+ * (ELOOP) at `node_modules/<name>` in front of an ancestor's real copy makes
3458
+ * the import load the ancestor's copy. This walk used to throw on every
3459
+ * failure but ENOENT and ENOTDIR, and `incompatibilityMap` turned the throw
3460
+ * into no verdict — so a missing peer, whose walk runs all the way to the
3461
+ * root, met any unsearchable or looping `node_modules` on the way (a
3462
+ * `~/node_modules` made by `sudo npm i` under umask 027, say), and every
3463
+ * missing-peer badge in the catalog went silent while those plugins still
3464
+ * failed to import.
3465
+ *
3466
+ * The walk stops at the first match whatever it finds inside: the loader
3467
+ * imports from that directory or fails there, and never falls through to an
3468
+ * ancestor (measured on 26.6.0: an empty `node_modules/<name>` in front of an
3469
+ * ancestor's working copy fails the import with ERR_MODULE_NOT_FOUND).
3470
+ * Whether the match is a PACKAGE is `packageDirectory`'s question.
3471
+ *
3472
+ * Throws only for a `spec` that is not a bare package name. No state of the
3473
+ * filesystem can make it throw.
3474
+ */
3475
+ function findPackageDir(fromDir, spec, fs) {
3476
+ if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
3477
+ const segments = spec.split("/");
3478
+ for (let dir = fromDir;;) {
3479
+ const candidate = join(dir, "node_modules", ...segments);
3480
+ if (isDirectoryAt(fs, candidate)) return candidate;
3481
+ const parent = dirname(dir);
3482
+ if (parent === dir) return null;
3483
+ dir = parent;
3484
+ }
3485
+ }
3486
+ /**
3487
+ * The directory `spec` is installed at as seen from `fromDir`, or null when
3488
+ * it is not installed there: the walk's match (`findPackageDir`), and only
3489
+ * when that directory holds a `package.json` that stats as a FILE. Anything
3490
+ * else is ABSENT — a match with no manifest, a manifest that is not a file,
3491
+ * one whose stat fails — because the loader matches that directory and the
3492
+ * import fails there. The case this exists for: an uninstall that stops
3493
+ * part-way (on Windows a locked file is enough) can leave
3494
+ * `<profile>/node_modules/L/` holding nothing but a nested `node_modules/`,
3495
+ * and a plugin declaring L then fails with ERR_MODULE_NOT_FOUND; while any
3496
+ * directory counted as present, the badge naming L disappeared. The lookup
3497
+ * before that one, `require.resolve('<spec>/package.json')`, required the
3498
+ * manifest too.
3499
+ *
3500
+ * The manifest is stat-ed and never read here: presence is the file, and
3501
+ * what it says is the version resolver's question. So a manifest that exists
3502
+ * but cannot be read or parsed is a present package with no readable version.
3503
+ *
3504
+ * One edge is knowingly given up: a directory holding `index.js` and no
3505
+ * manifest DOES import — Node's legacy main resolution tries `index.js` in a
3506
+ * match without a manifest — but reads absent here. No package manager
3507
+ * installs that shape.
3508
+ *
3509
+ * Throws only for a `spec` that is not a bare package name. This one lookup
3510
+ * is behind the presence resolver, the version resolver, and the running
3511
+ * harness's own `@deepseek-ai/dsh-app-boot` (`harness.ts`).
3512
+ */
3513
+ function packageDirectory(fromDir, spec, fs = NODE_LOOKUP_FS) {
3514
+ const match = findPackageDir(fromDir, spec, fs);
3515
+ return match !== null && isFileAt(fs, join(match, "package.json")) ? match : null;
3516
+ }
3517
+ /**
3518
+ * The directory the walk starts from. `createRequire` took a file URL or an
3519
+ * absolute path and so does this — the production anchor is
3520
+ * `<profile>/cordis.yml` as a file URL — and, like `createRequire`, a
3521
+ * trailing separator names the directory itself rather than a file in its
3522
+ * parent. Anything else throws here, at construction, which the gateway's
3523
+ * call sites already treat as "no anchor, no verdict".
3524
+ */
3525
+ function anchorDirectory(baseUrl) {
3526
+ const path = isAbsolute(baseUrl) ? baseUrl : fileURLToPath(baseUrl);
3527
+ return path.endsWith("/") || path.endsWith(sep) ? path : dirname(path);
3528
+ }
3529
+ /** `nodeResolver` over an injected filesystem, walking up from `fromDir` —
3530
+ * the seam fixtures drive the lookup through. */
3531
+ function packageResolver(fromDir, fs) {
3532
+ return (spec) => packageDirectory(fromDir, spec, fs) !== null;
3533
+ }
2823
3534
  /**
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.
3535
+ * The production resolver, anchored at the profile. A peer is present when
3536
+ * the first ancestor `node_modules/<spec>` that is a directory holds a
3537
+ * `package.json` file — the directory the ESM resolver that loads plugins'
3538
+ * host halves matches for a bare package, and a manifest in it
3539
+ * (`findPackageDir`, `packageDirectory`). From `<profile>/cordis.yml` the
3540
+ * walk reads `<profile>/node_modules`, then `$DSH_HOME/profiles/node_modules`
3541
+ * — the link farm dsh-app-boot's `healProfilesModuleFallback` keeps, pointing
3542
+ * into the global dsh install, on 0.1.5; 0.1.7 keeps none, and on a harness
3543
+ * that serves its packages another way `harnessPackageResolver` asks it in
3544
+ * front of this walk — and so on up to the root, past any candidate
3545
+ * it cannot stat, as Node's reader walks past one (measured on Node 26.6.0
3546
+ * with a looping link in front of a real copy: the import loads the copy).
3547
+ * The one shape where this and the loader knowingly disagree is a directory
3548
+ * holding `index.js` and no manifest, which imports and reads absent here;
3549
+ * no package manager installs it (`packageDirectory`).
3550
+ *
3551
+ * It used to ask `require.resolve('<spec>/package.json')`, a question that
3552
+ * answered wrongly twice:
3553
+ *
3554
+ * - Under a packaged dsh executable (`process.pkg`) the link farm holds ESM
3555
+ * proxy packages instead of symlinks, and a proxy's `exports` leaves
3556
+ * `./package.json` out on purpose, so resolving it threw
3557
+ * ERR_PACKAGE_PATH_NOT_EXPORTED for every proxied peer. Presence read that
3558
+ * as present; the version resolver below could read nothing. Reproduced:
3559
+ * a proxy at `0.2.0-rc.1` against a declared `^0.1.1-rc.2` gave presence
3560
+ * true, version null, mismatches `[]` — the load-time self-check could
3561
+ * never fire under a packaged dsh.
3562
+ * - Node caches a SUCCESSFUL CJS resolution for the life of the process
3563
+ * (`Module._pathCache`, keyed on request + lookup paths) and hands the
3564
+ * cached filename back without looking at the disk; a failure is not
3565
+ * cached. Measured on Node 26.6.0 in one process: absent → false,
3566
+ * installed → true, uninstalled → STILL true. A peer the reader removed
3567
+ * left every plugin declaring it unflagged until dsh restarted.
3568
+ *
3569
+ * So nothing is cached and `exports` is never consulted: every call stats
3570
+ * afresh, and a package with a manifest is present whatever its `exports`
3571
+ * says. `stat` follows symlinks, so a link to nothing reads absent — which
3572
+ * matters, because the link farm adds links and never prunes them: 29 of its
3573
+ * 511 dangled on the machine this was measured on, after upgrades.
3574
+ *
3575
+ * The global folders — `NODE_PATH`, `~/.node_modules`, `~/.node_libraries`,
3576
+ * `$PREFIX/lib/node` — are deliberately not searched: the ESM resolver that
3577
+ * loads plugin host code does not search them either. That is not academic.
3578
+ * pnpm's bin shims export `NODE_PATH` (this repo's own `node_modules/.bin/
3579
+ * vitest` points it into the virtual store), and under one the old lookup
3580
+ * found harness packages from any anchor at all, a temp directory included.
3581
+ *
3582
+ * It throws only for a `spec` that is not a bare package name, and
3583
+ * `incompatibilityMap` turns the throw into no verdict: a hostile name is not
3584
+ * a fact about this installation, and false is an accusation. Nothing on the
3585
+ * filesystem makes it throw. It used to throw on a stat failure other than
3586
+ * ENOENT or ENOTDIR as well, reasoning that such a candidate "may hold the
3587
+ * package" — but the loader never asks what a candidate it cannot stat holds;
3588
+ * it walks on (`findPackageDir`), so the throw silenced true badges and
3589
+ * established nothing.
2828
3590
  */
2829
3591
  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
- };
3592
+ return packageResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
3593
+ }
3594
+ /**
3595
+ * The manifest of the package the harness reaches for `spec` from
3596
+ * `parentURL`, or null when it reaches none, or one whose manifest no longer
3597
+ * stats as a file. The stat is fresh on every call for the reason §9.4 gives:
3598
+ * the service remembers a package it once found, and an uninstalled peer
3599
+ * must stop reading as present. Throws what the service throws, and a
3600
+ * TypeError for a name that is not a bare package name — which never reaches
3601
+ * the service, because peer names are catalog text.
3602
+ */
3603
+ function harnessManifest(lookup, spec, parentURL, fs) {
3604
+ if (!isBarePackageName(spec)) throw new TypeError(`not a bare package name: ${JSON.stringify(spec)}`);
3605
+ const manifestPath = lookup.packageOf(spec, parentURL)?.manifestPath;
3606
+ return typeof manifestPath === "string" && isFileAt(fs, manifestPath) ? manifestPath : null;
3607
+ }
3608
+ /**
3609
+ * Presence on a harness that serves its packages through its runtime
3610
+ * resolution (`HarnessPackageLookup`): present when the harness reaches a
3611
+ * package for `spec` from `parentURL`, the profile anchor, and otherwise
3612
+ * whatever `walk` answers — `nodeResolver` over the same anchor, in
3613
+ * production. Asking the harness first asks the loader's own question, since
3614
+ * a plugin's import goes through the same resolution. The walk stays behind
3615
+ * it because either answer can only clear a badge, never raise one, and a
3616
+ * false badge is the failure this module exists to avoid.
3617
+ *
3618
+ * Throws, which `incompatibilityMap` turns into no verdict for the entries
3619
+ * declaring `spec`, for a name that is not a bare package name and for
3620
+ * anything the service throws. A harness that cannot answer is silence:
3621
+ * falling back to the walk alone would read every package it serves as
3622
+ * missing.
3623
+ */
3624
+ function harnessPackageResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
3625
+ return (spec) => harnessManifest(lookup, spec, parentURL, fs) !== null || walk(spec);
2842
3626
  }
2843
3627
  /**
2844
3628
  * Package name → the peer names that did not resolve. A key is present only
@@ -2876,41 +3660,91 @@ function incompatibilityMap(entries, resolve) {
2876
3660
  }
2877
3661
  return out;
2878
3662
  }
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);
3663
+ /** `nodeVersionResolver` over an injected filesystem, walking up from
3664
+ * `fromDir` — the seam fixtures drive the lookup through. */
3665
+ function packageVersionResolver(fromDir, fs) {
2889
3666
  return (spec) => {
2890
- let manifestPath;
2891
- try {
2892
- manifestPath = require.resolve(`${spec}/package.json`);
2893
- } catch {
2894
- return null;
2895
- }
3667
+ let dir;
2896
3668
  try {
2897
- const version = JSON.parse(readFileSync(manifestPath, "utf8")).version;
2898
- return typeof version === "string" ? version : null;
3669
+ dir = packageDirectory(fromDir, spec, fs);
2899
3670
  } catch {
2900
3671
  return null;
2901
3672
  }
3673
+ return dir === null ? null : manifestVersion(fs, join(dir, "package.json"));
2902
3674
  };
2903
3675
  }
3676
+ /** The non-empty `version` string of the manifest at `manifestPath`, or null
3677
+ * for anything else. */
3678
+ function manifestVersion(fs, manifestPath) {
3679
+ let manifest;
3680
+ try {
3681
+ manifest = JSON.parse(fs.readFile(manifestPath));
3682
+ } catch {
3683
+ return null;
3684
+ }
3685
+ const version = typeof manifest === "object" && manifest !== null ? manifest.version : void 0;
3686
+ return typeof version === "string" && version.length > 0 ? version : null;
3687
+ }
2904
3688
  /**
2905
- * The declared peers whose provided version is outside the declared range,
2906
- * ordered by peer name. Pure: the module graph arrives through `resolve`.
3689
+ * The production version resolver: `nodeResolver`'s lookup, then the matched
3690
+ * directory's own `package.json`, read directly. Reading it directly is the
3691
+ * fix, not a shortcut: resolving `<spec>/package.json` is what an exports map
3692
+ * can refuse, and a packaged dsh's module proxy refuses it by construction
3693
+ * while carrying the proxied package's version in exactly that file (see
3694
+ * `nodeResolver`).
2907
3695
  *
2908
- * `includePrerelease` is load-bearing, not a convenience. The harness ships
2909
- * nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
2910
- * every later rc on the same 0.1 line — including whichever one is installed
2911
- * and working, named as a property because that version moves and a comment
2912
- * naming it goes stale in place — and every future rc bump would raise a
2913
- * false alarm. With it on, the range still excludes an older prerelease
3696
+ * The directory is the one the presence resolver finds present — the walk's
3697
+ * first directory match, holding a `package.json` file (`packageDirectory`;
3698
+ * `findPackageDir` cites Node's reader) — so the two never disagree about
3699
+ * which copy is installed: a candidate nobody can stat is walked past to the
3700
+ * copy further up, whose version is then the answer, exactly as the loader
3701
+ * imports that copy (measured on Node 26.6.0 with a looping link in front of
3702
+ * it); a match without a manifest is absent, and shadows every copy above
3703
+ * it. An `index.js`-only directory, which the loader would import, has no
3704
+ * manifest to read a version from either way.
3705
+ *
3706
+ * Anything that leaves no version to read answers null: a spec that is not a
3707
+ * bare package name, a peer that is absent, a manifest that is unreadable or
3708
+ * malformed, a `version` that is not a non-empty string. Absence is
3709
+ * deliberately NOT reported as a version violation: `incompatibilityMap` is
3710
+ * what covers a missing peer.
3711
+ */
3712
+ function nodeVersionResolver(baseUrl) {
3713
+ return packageVersionResolver(anchorDirectory(baseUrl), NODE_LOOKUP_FS);
3714
+ }
3715
+ /**
3716
+ * `harnessPackageResolver`'s twin for versions: read afresh from the manifest
3717
+ * of the package the harness reaches for `spec` — the copy a plugin's import
3718
+ * loads, which the walk cannot see on dsh 0.1.7 — and `walk`'s answer only
3719
+ * when the harness reaches none. A manifest the harness names but nobody can
3720
+ * read or parse answers null, never the walk's version: the walk may match
3721
+ * another copy, and the version must describe the copy presence found (see
3722
+ * `nodeVersionResolver`).
3723
+ *
3724
+ * Null for a name that is not a bare package name and for a service that
3725
+ * throws, as null is already this resolver's no-verdict signal.
3726
+ */
3727
+ function harnessPackageVersionResolver(walk, lookup, parentURL, fs = NODE_LOOKUP_FS) {
3728
+ return (spec) => {
3729
+ let manifestPath;
3730
+ try {
3731
+ manifestPath = harnessManifest(lookup, spec, parentURL, fs);
3732
+ } catch {
3733
+ return null;
3734
+ }
3735
+ return manifestPath === null ? walk(spec) : manifestVersion(fs, manifestPath);
3736
+ };
3737
+ }
3738
+ /**
3739
+ * The declared peers whose provided version is outside the declared range,
3740
+ * ordered by peer name. Pure: the module graph arrives through `resolve`.
3741
+ *
3742
+ * `includePrerelease` is load-bearing, not a convenience. The harness ships
3743
+ * nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
3744
+ * every later rc on the same 0.1 line — including whichever one is installed
3745
+ * and working, named as a property because that version moves and a comment
3746
+ * naming it goes stale in place — and every future rc bump would raise a
3747
+ * false alarm. With it on, the range still excludes an older prerelease
2914
3748
  * (`0.1.1-rc.1`) and a minor- or major-line move
2915
3749
  * (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
2916
3750
  * path. Discrimination on both sides is the whole point: one false warning
@@ -2974,6 +3808,700 @@ function createPeerVersionCheck(deps) {
2974
3808
  };
2975
3809
  }
2976
3810
  //#endregion
3811
+ //#region src/host/harness.ts
3812
+ /**
3813
+ * The harness this process runs: which `@deepseek-ai/dsh` it is, and that
3814
+ * dsh's own profile-template table — the running side of the
3815
+ * `dsh.compatibility` verdict (design 2026-09-01-harness-compatibility §9.9).
3816
+ *
3817
+ * Both used to be read from somewhere that is not the running harness, and
3818
+ * both were wrong there:
3819
+ *
3820
+ * - The version was the first `node_modules/@deepseek-ai/dsh` found walking up
3821
+ * from the profile: what the profile can IMPORT, not what RUNS. A plugin
3822
+ * that depends on the dsh package gets its copy hoisted into
3823
+ * `<profile>/node_modules` — the listed `dsh-claude-tui@0.1.6` pulls
3824
+ * 0.1.2-rc.1 — a second install sharing DSH_HOME re-points the link farm,
3825
+ * and under a packaged dsh the bin-only package gets no link-farm proxy at
3826
+ * all, so the read was dead or stale. The first was reproduced with real
3827
+ * installs on 2026-09-25 (the design's section 9.9 amendment): every
3828
+ * verdict said "running 0.1.2-rc.1" while 0.1.5-rc.3 ran.
3829
+ * - The templates came from the shop's own `import * as appBoot`, which
3830
+ * resolves from the SHOP's real path: under a `link:` install that is this
3831
+ * repository's devDependency, 0.1.1-rc.2 with two templates, while
3832
+ * 0.1.5-rc.3 with five runs — and in production a copy hoisted from some
3833
+ * plugin's dependencies can shadow it the same way.
3834
+ *
3835
+ * Both now come from the one thing that names the running harness: the script
3836
+ * this process was started with. Resolved through symlinks, the package that
3837
+ * owns it IS the dsh CLI that is running, if it is one at all; its manifest
3838
+ * says its version, and the app-boot it imports says its templates.
3839
+ */
3840
+ /** The package whose `PROFILE_TEMPLATES` the running dsh composes profiles
3841
+ * from. */
3842
+ const APP_BOOT = "@deepseek-ai/dsh-app-boot";
3843
+ /** What is known when nothing says which harness runs. */
3844
+ function noHarness() {
3845
+ return {
3846
+ dshVersion: null,
3847
+ templates: profileTemplatesOf(void 0),
3848
+ patchLists: null,
3849
+ peerCheck: null
3850
+ };
3851
+ }
3852
+ /** Whether `path` stats as a regular file. */
3853
+ function isFile(path) {
3854
+ try {
3855
+ return statSync(path).isFile();
3856
+ } catch {
3857
+ return false;
3858
+ }
3859
+ }
3860
+ /**
3861
+ * The package that owns `start`: the FIRST directory at or above it holding a
3862
+ * `package.json` file, and its parsed manifest — or null when there is none
3863
+ * up to the root, or when that manifest cannot be read or parsed. The walk
3864
+ * stops at the first manifest whatever it names, the rule `owningEntry` in
3865
+ * `dsh-cli.ts` follows: a script owned by some other package is not dsh's,
3866
+ * and climbing on would find an unrelated ancestor — a workspace root, or dsh
3867
+ * itself above a package vendored inside it.
3868
+ */
3869
+ function owningPackage(start) {
3870
+ for (let dir = start;;) {
3871
+ const manifestPath = join(dir, "package.json");
3872
+ if (isFile(manifestPath)) try {
3873
+ return {
3874
+ dir,
3875
+ manifest: JSON.parse(readFileSync(manifestPath, "utf8"))
3876
+ };
3877
+ } catch {
3878
+ return null;
3879
+ }
3880
+ const parent = dirname(dir);
3881
+ if (parent === dir) return null;
3882
+ dir = parent;
3883
+ }
3884
+ }
3885
+ /**
3886
+ * What the app-boot the dsh at `dshDir` imports says about that dsh: its
3887
+ * template table, and whether it reads a list-valued bundle patch. Null when
3888
+ * no app-boot is on the resolution path.
3889
+ *
3890
+ * Found with the node_modules walk the peer check uses (`packageDirectory`)
3891
+ * from the dsh package directory — the copy nested under dsh first, then one
3892
+ * beside it, as dsh's own import finds it — and never through `NODE_PATH`:
3893
+ * pnpm's bin shims export one, and a bare CJS lookup finds a package through
3894
+ * it from any directory at all. Only the ABSOLUTE directory then reaches
3895
+ * Node's CJS resolver, which reads its `main` (an `exports` map applies to
3896
+ * bare specifiers only), and the entry is imported by file URL, which Windows
3897
+ * requires of an absolute path. Under the dsh that runs, that URL is the one
3898
+ * dsh itself loaded — the CJS resolver realpaths as the ESM loader does, and
3899
+ * app-boot's `main` and `exports` name the same file — so the import is a
3900
+ * cache hit, not a second instance. Measured against a global npm install of
3901
+ * 0.1.5-rc.3: this chain and the ESM resolution from dsh's own `lib/bin.js`
3902
+ * give the same URL, and the reader, handed `<prefix>/bin/dsh`, answers
3903
+ * 0.1.5-rc.3 with all five templates.
3904
+ *
3905
+ * Throws for whatever that chain cannot do; `readRunningHarness` turns any
3906
+ * throw into an empty table, an unknown `patchLists` and no peer check.
3907
+ */
3908
+ async function appBootFacts(dshDir) {
3909
+ const appBootDir = packageDirectory(dshDir, APP_BOOT);
3910
+ if (appBootDir === null) return null;
3911
+ const entry = createRequire(import.meta.url).resolve(appBootDir);
3912
+ const exported = await import(pathToFileURL(entry).href);
3913
+ return {
3914
+ templates: profileTemplatesOf(exported.PROFILE_TEMPLATES),
3915
+ patchLists: typeof exported.bundlePatchFiles === "function",
3916
+ peerCheck: peerCheckOf(exported, appBootDir)
3917
+ };
3918
+ }
3919
+ /**
3920
+ * The peer check an app-boot exports, bound to its own version (see
3921
+ * `PeerCheck.evaluate`), or null when it exports only part of one — a rule
3922
+ * without the exemptions would refuse what a profile has already allowed —
3923
+ * or when its manifest carries no version, which is the one `evaluate`
3924
+ * judges by. Never throws, so a check that cannot be formed costs the
3925
+ * templates and the patch-list answer nothing.
3926
+ */
3927
+ function peerCheckOf(exported, appBootDir) {
3928
+ const { evaluatePluginCompatibility: evaluate, readProfileVersionExemptions: exemptions } = exported;
3929
+ if (typeof evaluate !== "function" || typeof exemptions !== "function") return null;
3930
+ let version;
3931
+ try {
3932
+ version = JSON.parse(readFileSync(join(appBootDir, "package.json"), "utf8")).version;
3933
+ } catch {
3934
+ return null;
3935
+ }
3936
+ if (typeof version !== "string" || version.length === 0) return null;
3937
+ const runtime = version;
3938
+ const judge = evaluate;
3939
+ return {
3940
+ evaluate: (manifest, profileExemptions) => judge(manifest, profileExemptions, runtime),
3941
+ exemptions
3942
+ };
3943
+ }
3944
+ /**
3945
+ * The harness this process runs, identified from `script` — the gateway's
3946
+ * `restartScript`, `process.argv[1]` in production — or nothing, never a
3947
+ * guess:
3948
+ *
3949
+ * 1. `script` is resolved through symlinks, because `argv[1]` is the path the
3950
+ * shell ran — `<prefix>/bin/dsh`, a link — and only its target lies inside
3951
+ * the dsh package. No script, or one that does not resolve: nothing known.
3952
+ * An empty path is no script either: `realpathSync('')` answers the
3953
+ * working directory, which says where dsh was started, not what it is.
3954
+ * 2. The package that owns it (`owningPackage`) must be `@deepseek-ai/dsh`.
3955
+ * Anything else — a test runner, another host embedding the shop — means
3956
+ * nothing here can say which harness runs, and both halves of the verdict
3957
+ * stay silent.
3958
+ * 3. `dshVersion` is that manifest's `version` when it is a non-empty string.
3959
+ * 4. `templates`, `patchLists` and `peerCheck` come from that dsh's own
3960
+ * app-boot (`appBootFacts`). Any failure there costs all three and keeps
3961
+ * the version.
3962
+ *
3963
+ * Never throws and never rejects: a harness nobody could identify forms no
3964
+ * verdict, and must never be the reason the catalog fails to load.
3965
+ */
3966
+ async function readRunningHarness(script) {
3967
+ try {
3968
+ if (script === void 0 || script === "") return noHarness();
3969
+ const owner = owningPackage(dirname(realpathSync(script)));
3970
+ if (owner === null || typeof owner.manifest !== "object" || owner.manifest === null) return noHarness();
3971
+ const { name, version } = owner.manifest;
3972
+ if (name !== "@deepseek-ai/dsh") return noHarness();
3973
+ const dshVersion = typeof version === "string" && version.length > 0 ? version : null;
3974
+ let facts;
3975
+ try {
3976
+ facts = await appBootFacts(owner.dir);
3977
+ } catch {
3978
+ facts = null;
3979
+ }
3980
+ return {
3981
+ dshVersion,
3982
+ templates: facts?.templates ?? profileTemplatesOf(void 0),
3983
+ patchLists: facts?.patchLists ?? null,
3984
+ peerCheck: facts?.peerCheck ?? null
3985
+ };
3986
+ } catch {
3987
+ return noHarness();
3988
+ }
3989
+ }
3990
+ //#endregion
3991
+ //#region src/host/plugin-manager.ts
3992
+ /**
3993
+ * dsh's `pluginManager` service (0.1.7 on), as far as this shop calls it:
3994
+ * design 2026-09-26-plugin-manager-delegation. Structural throughout: the
3995
+ * build compiles against the 0.1.1-rc.2 harness floor, where
3996
+ * `@deepseek-ai/dsh-plugin-manager` does not exist.
3997
+ */
3998
+ const REQUIRED = [
3999
+ "installBundle",
4000
+ "removeBundle",
4001
+ "setPluginEnabled",
4002
+ "setBundleEnabled"
4003
+ ];
4004
+ /** The service, when `service` offers every required operation; else null.
4005
+ * All or nothing: half a service would take an install and fail its
4006
+ * uninstall. */
4007
+ function asPluginManager(service) {
4008
+ if (typeof service !== "object" || service === null) return null;
4009
+ const candidate = service;
4010
+ if (!REQUIRED.every((method) => typeof candidate[method] === "function")) return null;
4011
+ if (candidate.cancelInstall !== void 0 && typeof candidate.cancelInstall !== "function") return null;
4012
+ return service;
4013
+ }
4014
+ const text = (value) => typeof value === "string" ? value : null;
4015
+ const record = (value) => typeof value === "object" && value !== null ? value : {};
4016
+ function incompatibleList(value) {
4017
+ if (!Array.isArray(value)) return [];
4018
+ return value.flatMap((item) => {
4019
+ const entry = record(item);
4020
+ const name = text(entry.name);
4021
+ const version = text(entry.version);
4022
+ const runtimeVersion = text(entry.runtimeVersion);
4023
+ if (name === null || version === null || runtimeVersion === null) return [];
4024
+ return [{
4025
+ name,
4026
+ version,
4027
+ runtimeVersion,
4028
+ peers: Object.fromEntries(Object.entries(record(entry.peers)).filter((pair) => typeof pair[1] === "string"))
4029
+ }];
4030
+ });
4031
+ }
4032
+ /** Read one `ChangeResult`. Every field is optional: one a later harness
4033
+ * drops reads as absent, never as a crash. */
4034
+ function readChange(raw) {
4035
+ const result = record(raw);
4036
+ const error = record(result.error);
4037
+ const run = record(result.packageResult);
4038
+ const fromError = incompatibleList(error.incompatible);
4039
+ return {
4040
+ application: text(result.application),
4041
+ stage: text(result.stage),
4042
+ errorCode: text(error.code),
4043
+ diagnostic: text(error.diagnostic),
4044
+ incompatible: fromError.length > 0 ? fromError : incompatibleList(run.incompatible),
4045
+ kind: text(run.kind),
4046
+ output: text(run.output) ?? "",
4047
+ pendingBuilds: Array.isArray(result.pendingBuilds) ? result.pendingBuilds.filter((name) => typeof name === "string") : [],
4048
+ failedAt: text(result.failedAt),
4049
+ changed: typeof result.changed === "boolean" ? result.changed : null,
4050
+ truncated: run.truncated === true
4051
+ };
4052
+ }
4053
+ /** True when `sentence` names a `dsh plugin` command. */
4054
+ function namesCliCommand(sentence) {
4055
+ return sentence.includes("dsh plugin");
4056
+ }
4057
+ const isTerminal = (character) => character === "." || character === "!" || character === "?";
4058
+ const isSpace = (character) => /\s/.test(character);
4059
+ /** Splits `line` into sentences, each still carrying whatever whitespace
4060
+ * followed its `.`, `!` or `?`, so joining every piece back together
4061
+ * reproduces `line` exactly. A sentence ends after a run of terminal
4062
+ * punctuation that whitespace or the end of the line follows; the text after
4063
+ * the last such run is one more piece, so a line with no terminal
4064
+ * punctuation at all is one sentence spanning the whole line, and an empty
4065
+ * line gives none. One pass from left to right: the regex this replaced
4066
+ * backtracked quadratically on a long run of punctuation with no whitespace
4067
+ * after it, and dsh's output holds up to 16 KB of whatever pnpm printed. */
4068
+ function sentencesIn(line) {
4069
+ const pieces = [];
4070
+ let start = 0;
4071
+ let index = 0;
4072
+ while (index < line.length) {
4073
+ if (!isTerminal(line.charAt(index))) {
4074
+ index += 1;
4075
+ continue;
4076
+ }
4077
+ let runEnd = index;
4078
+ while (runEnd < line.length && isTerminal(line.charAt(runEnd))) runEnd += 1;
4079
+ if (runEnd < line.length && !isSpace(line.charAt(runEnd))) {
4080
+ index = runEnd;
4081
+ continue;
4082
+ }
4083
+ let end = runEnd;
4084
+ while (end < line.length && isSpace(line.charAt(end))) end += 1;
4085
+ pieces.push(line.slice(start, end));
4086
+ start = end;
4087
+ index = end;
4088
+ }
4089
+ if (start < line.length) pieces.push(line.slice(start));
4090
+ return pieces;
4091
+ }
4092
+ /** Where `sentence`'s terminator starts: its closing punctuation and the
4093
+ * whitespace after it. Scanned back from the end, so no run in dsh's output
4094
+ * can make it slow. */
4095
+ function terminatorStart(sentence) {
4096
+ let end = sentence.length;
4097
+ while (end > 0 && /\s/.test(sentence.charAt(end - 1))) end -= 1;
4098
+ while (end > 0 && ".!?".includes(sentence.charAt(end - 1))) end -= 1;
4099
+ return end;
4100
+ }
4101
+ /** `sentence`, with every clause that names a `dsh plugin` command removed.
4102
+ * A clause is a run between `; ` separators, and what is kept keeps the
4103
+ * sentence's terminator. A sentence with nothing to remove is returned
4104
+ * unchanged; one that loses every clause is dropped (null). */
4105
+ function scrubSentence(sentence) {
4106
+ if (!namesCliCommand(sentence)) return sentence;
4107
+ const end = terminatorStart(sentence);
4108
+ const kept = sentence.slice(0, end).split("; ").filter((clause) => !namesCliCommand(clause));
4109
+ return kept.length === 0 ? null : `${kept.join("; ")}${sentence.slice(end)}`;
4110
+ }
4111
+ /** `line`, with every clause that names a `dsh plugin` command removed (see
4112
+ * `scrubSentence`). A line with nothing to remove is returned unchanged (so
4113
+ * it stays exactly what it was); one that loses every sentence is dropped
4114
+ * (null). */
4115
+ function scrubLine(line) {
4116
+ const sentences = sentencesIn(line);
4117
+ if (sentences.length === 0) return line;
4118
+ const kept = sentences.map(scrubSentence).filter((sentence) => sentence !== null);
4119
+ return kept.length === 0 ? null : kept.join("");
4120
+ }
4121
+ function scrubText(value) {
4122
+ return value.split("\n").map(scrubLine).filter((line) => line !== null).join("\n");
4123
+ }
4124
+ /** `change`, with every clause naming a `dsh plugin` command removed from
4125
+ * `diagnostic` and `output`: dsh's CLI refuses every `plugin` subcommand for
4126
+ * the desktop profile, so a step dsh wrote assuming that command exists must
4127
+ * not reach a reader there. Two dsh texts this exists for: dsh-app-boot's
4128
+ * `pluginCompatibilityWarning` ("... with `dsh plugin allow-version` or the
4129
+ * plugin manager ..."), whose sentence goes whole, and the restoration line
4130
+ * after a failed repair ("..., but node_modules could not be reinstalled;
4131
+ * run 'dsh plugin install'"), which keeps what it says dsh restored. A
4132
+ * clause is a run between `; ` separators within a sentence; a sentence left
4133
+ * with no clause is dropped, and so is a line left with no sentence. Every
4134
+ * other line stays untouched, and a diagnostic left empty afterward reads as
4135
+ * absent, the same way `codeReason` already treats one. */
4136
+ function forDesktopReader(change) {
4137
+ return {
4138
+ ...change,
4139
+ diagnostic: change.diagnostic === null ? null : scrubText(change.diagnostic),
4140
+ output: scrubText(change.output)
4141
+ };
4142
+ }
4143
+ /** The end of a detail that passes on what a service call threw: `: ` and
4144
+ * the message, scrubbed for a desktop reader as `forDesktopReader` scrubs
4145
+ * dsh's answer, since a thrown message is dsh's text as well. A message left
4146
+ * with nothing to say ends the detail with a period instead. */
4147
+ function thrownTail(message, desktop) {
4148
+ const said = desktop ? scrubText(message) : message;
4149
+ return said.trim() === "" ? "." : `: ${said}`;
4150
+ }
4151
+ /** What each management code means, in dsh 0.1.7-rc.2's own terms (the
4152
+ * implementation plan of 2026-09-27, Task 2, records where each is raised). */
4153
+ const CODE_SENTENCE = {
4154
+ "unknown-plugin": "dsh lists no plugin entry by that id",
4155
+ "invalid-spec": "dsh could not read the install spec",
4156
+ "ambiguous-install": "dsh could not tell which package the install added",
4157
+ "not-bundle": "the package is not a dsh bundle",
4158
+ "not-removable": "dsh does not allow removing this bundle",
4159
+ "stop-profile": "the bundle is running and this dsh cannot unload it live, so the profile has to be stopped first",
4160
+ "bundle-in-use": "the bundle was switched off, but some of its plugins are still running",
4161
+ "stale-approval": "a build approval names a package pnpm no longer holds",
4162
+ "management-required": "the plugin belongs to dsh itself",
4163
+ unaddressable: "the plugin is not a row of the profile's own patch, so dsh cannot address it",
4164
+ "operation-error": "dsh hit an unexpected error"
4165
+ };
4166
+ /** What each classified pnpm failure was, as dsh classifies it. */
4167
+ const KIND_SENTENCE = {
4168
+ "pnpm-missing": "pnpm could not be started",
4169
+ timeout: "pnpm did not finish within dsh's own time bound",
4170
+ "not-found": "no such package was found",
4171
+ "no-matching-version": "no version matching the requested one was found",
4172
+ network: "the network failed",
4173
+ "disk-full": "the disk is full",
4174
+ permission: "permission was denied",
4175
+ integrity: "the downloaded package failed its integrity check"
4176
+ };
4177
+ const WHERE = {
4178
+ registry: " at the registry",
4179
+ "spec-host": " at the host the package is fetched from"
4180
+ };
4181
+ /** An own-property read of one of the three tables above. They are object
4182
+ * literals, so a bare index read answers for Object.prototype, and a code of
4183
+ * `toString` would publish a function's source as dsh's reason (R43). */
4184
+ const lookup = (table, key) => Object.hasOwn(table, key) ? table[key] : void 0;
4185
+ /** The pnpm-failure hint a desktop reader gets: it names no command, which
4186
+ * the CLI would refuse for that profile. */
4187
+ const DESKTOP_FAILURE_HINT = "pnpm failed in the profile";
4188
+ /** What dsh says it restored after refusing, from its own restoration line:
4189
+ * the first `dsh: ` line after the one opening the refusal
4190
+ * (dsh-plugin-manager's `rejected`), read as the CLI path's `refusalDetail`
4191
+ * reads it, capitalized and ending in dsh's own period. Null when the output
4192
+ * holds no such line, being empty or cut short. */
4193
+ function restorationSentence(output) {
4194
+ const lines = output.split(/\r?\n/);
4195
+ const start = lines.findIndex((line) => line.startsWith(REFUSAL_OPENER));
4196
+ if (start === -1) return null;
4197
+ const said = lines.slice(start + 1).find((later) => later.startsWith("dsh: "))?.slice(5).trim() ?? "";
4198
+ return said === "" ? null : `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
4199
+ }
4200
+ /** The refusal of a package the running dsh rejects on its peers, for the
4201
+ * operation dsh refused: an install, an update, or an uninstall whose pnpm
4202
+ * run touched an incompatible sibling. What dsh restored is dsh's to say,
4203
+ * so its restoration line is passed on. Without one, an install or update
4204
+ * says what dsh's own pre-check says, and an uninstall says nothing of it. */
4205
+ function versionRefusalDetail(context, change) {
4206
+ const refused = change.incompatible.map((issue) => `${issue.name}@${issue.version} declares ${Object.entries(issue.peers).map(([peer, range]) => `${peer} ${range}`).join(", ")}, which dsh ${issue.runtimeVersion} does not satisfy`);
4207
+ const restored = restorationSentence(change.output) ?? (context.operation === "uninstall" || change.truncated ? null : "Nothing was installed.");
4208
+ const base = `dsh-plugin-shop: dsh refused the ${context.operation}: ${refused.join("; ")}.${restored === null ? "" : ` ${restored}`}`;
4209
+ if (context.desktop) return `${base} dsh's CLI, which grants version exemptions, does not manage the desktop profile, and this shop grants none.`;
4210
+ const commands = change.incompatible.map((issue) => allowVersionCommand(context.profile, issue)).filter((command) => command !== null);
4211
+ if (commands.length === 0) return base;
4212
+ return `${base} To accept the risk of crashes or data loss for ${commands.length === 1 ? "this exact version" : "these exact versions"}, run: ${commands.join("; ")} - then ${context.operation} again.`;
4213
+ }
4214
+ /** The sentence a management code gets, plus dsh's own diagnostic. The
4215
+ * diagnostic is trimmed and stripped of one trailing period so one that
4216
+ * already ends in a period is not doubled; empty after that reads as
4217
+ * absent. Exported: Task 7 builds the same tail for a non-terminal notice. */
4218
+ function codeReason(change, code) {
4219
+ const sentence = lookup(CODE_SENTENCE, code);
4220
+ const diagnostic = change.diagnostic !== null ? change.diagnostic.trim().replace(/\.$/, "") : "";
4221
+ const said = diagnostic !== "" ? ` dsh reported: ${diagnostic}.` : "";
4222
+ return `${sentence !== void 0 ? `: ${sentence}` : ""}.${said}`;
4223
+ }
4224
+ /** A desktop reader's detail for build scripts pnpm held. The app bundles
4225
+ * its own package manager, so `pnpm approve-builds` is no step for this
4226
+ * reader, and the shop never allows build scripts itself. dsh's own Plugins
4227
+ * page offers "Allow these scripts and retry" on the failed screen of an
4228
+ * install it runs, listing the held packages (dsh-client-ui-plugin-manager
4229
+ * 0.1.7-rc.2: `installApproveAndRetry` in lib/client.js, and its README), so
4230
+ * that is named, but only for an install whose held builds dsh listed. Its
4231
+ * Add plugin dialog refuses a name the profile already holds, so it cannot
4232
+ * rerun an update, and with no name listed it offers no such button. */
4233
+ function desktopBuildsDetail(context, change, held) {
4234
+ const base = `dsh-plugin-shop: pnpm is holding the build scripts of ${held}, which it blocks by default, and this shop never allows them.`;
4235
+ if (context.operation !== "install" || change.pendingBuilds.length === 0) return base;
4236
+ return `${base} dsh's Plugins page offers "Allow these scripts and retry" when an install it runs stops on them.`;
4237
+ }
4238
+ /** What a `cancelled` answer means, for every reader: the shop's deadline
4239
+ * asked dsh to stop the operation, and dsh answers `cancelled` only once its
4240
+ * repository check or pnpm has exited and package.json and pnpm-lock.yaml
4241
+ * are back as they were (dsh-plugin-manager 0.1.7-rc.2: cancelInstall, and
4242
+ * InstallCancelledError, thrown after the files are restored). Nothing was
4243
+ * installed, so the detail names no command: the CLI's `install`, which the
4244
+ * CLI path's own timeout detail names, would no longer involve the package.
4245
+ * Only installBundle can be cancelled, so the operation is an install or an
4246
+ * update. */
4247
+ function cancelledDetail(context) {
4248
+ const seconds = Math.max(1, Math.round(context.timeoutMs / 1e3));
4249
+ return `dsh-plugin-shop: the ${context.operation} did not finish within ${seconds}s, so the shop cancelled it. dsh stopped it, restored the profile's package.json and pnpm-lock.yaml, and installed nothing. Try the ${context.operation} again from the shop.`;
4250
+ }
4251
+ /** A `ChangeResult` as the shop's terminal install record: design
4252
+ * 2026-09-26-plugin-manager-delegation, section 5, read by the first rule
4253
+ * that matches. A removal that failed after dsh changed the profile says,
4254
+ * after whichever rule read it, that the package is still installed and
4255
+ * switched off. */
4256
+ function managerOutcome(raw, context) {
4257
+ const change = context.desktop ? forDesktopReader(readChange(raw)) : readChange(raw);
4258
+ const outcome = outcomeByRule(change, context);
4259
+ if (context.operation === "uninstall" && outcome.state === "failed" && change.changed === true) {
4260
+ const switchedOff = `${context.name} is still installed, but dsh has switched it off.`;
4261
+ if (outcome.detail === void 0) return {
4262
+ ...outcome,
4263
+ detail: switchedOff
4264
+ };
4265
+ const needsPeriod = !/[.!?]$/.test(outcome.detail);
4266
+ return {
4267
+ ...outcome,
4268
+ detail: `${outcome.detail}${needsPeriod ? "." : ""} ${switchedOff}`
4269
+ };
4270
+ }
4271
+ return outcome;
4272
+ }
4273
+ /** The section 5 rules, in order; the first that matches wins. */
4274
+ function outcomeByRule(change, context) {
4275
+ if (change.errorCode === "incompatible-version" || change.errorCode === "operation-error" && change.incompatible.length > 0) return {
4276
+ state: "failed",
4277
+ detail: versionRefusalDetail(context, change)
4278
+ };
4279
+ if (change.application === "failed" && change.stage === "enable") {
4280
+ const code = change.errorCode ?? "failed";
4281
+ return {
4282
+ state: "failed",
4283
+ detail: `dsh-plugin-shop: ${context.name} is installed, but dsh could not enable it (${code})${codeReason(change, code)} Uninstall it from the shop to undo the install.`
4284
+ };
4285
+ }
4286
+ if (change.application === "failed" && change.kind !== null && (change.errorCode === null || change.errorCode === "operation-error")) {
4287
+ if (change.kind === "build-blocked") {
4288
+ const held = change.pendingBuilds.length > 0 ? change.pendingBuilds.join(", ") : "a dependency";
4289
+ if (context.desktop) return {
4290
+ state: "failed",
4291
+ detail: desktopBuildsDetail(context, change, held)
4292
+ };
4293
+ return {
4294
+ state: "failed",
4295
+ detail: `dsh-plugin-shop: pnpm is holding the build scripts of ${held}, which it blocks by default: run \`pnpm approve-builds\` in the profile directory to allow them, then ${context.operation} again.`
4296
+ };
4297
+ }
4298
+ if (change.kind === "timeout" && change.failedAt === "spec-host") return {
4299
+ state: "failed",
4300
+ detail: `dsh-plugin-shop: the ${context.operation} failed at the host the package is fetched from: dsh checks that a GitHub repository is reachable before pnpm runs, and that check did not finish within its bound (5 s by default), so pnpm never ran.`
4301
+ };
4302
+ const sentence = lookup(KIND_SENTENCE, change.kind);
4303
+ if (sentence !== void 0) {
4304
+ const where = change.failedAt === null ? "" : lookup(WHERE, change.failedAt) ?? "";
4305
+ return {
4306
+ state: "failed",
4307
+ detail: `dsh-plugin-shop: the ${context.operation} failed${where}: ${sentence}.`
4308
+ };
4309
+ }
4310
+ const lines = change.output.split(/\r?\n/).filter((line) => line !== "");
4311
+ return {
4312
+ state: "failed",
4313
+ detail: context.desktop ? installFailureDetail(context.profile, lines, DESKTOP_FAILURE_HINT) : installFailureDetail(context.profile, lines)
4314
+ };
4315
+ }
4316
+ if (change.errorCode !== null) return {
4317
+ state: "failed",
4318
+ detail: `dsh-plugin-shop: dsh ${change.errorCode === "operation-error" ? `could not ${context.operation} ${context.name}` : `refused the ${context.operation} of ${context.name}`} (${change.errorCode})${codeReason(change, change.errorCode)}`
4319
+ };
4320
+ if (change.application === "cancelled") return {
4321
+ state: "failed",
4322
+ detail: cancelledDetail(context)
4323
+ };
4324
+ if (change.application === "restart-required") return context.operation === "update" ? {
4325
+ state: "done",
4326
+ activation: "restart",
4327
+ restartReason: "already-loaded"
4328
+ } : {
4329
+ state: "done",
4330
+ activation: "restart"
4331
+ };
4332
+ if (change.application === "applied" || change.application === "overridden") {
4333
+ const note = change.application === "overridden" ? { detail: "Saved, but a higher-priority layer (the home or invocation patch) decides whether it runs." } : {};
4334
+ if (context.operation !== "uninstall" && context.alreadyImported) return {
4335
+ state: "done",
4336
+ activation: "restart",
4337
+ restartReason: "already-loaded",
4338
+ ...note
4339
+ };
4340
+ return {
4341
+ state: "done",
4342
+ activation: activationOf({
4343
+ hostLive: true,
4344
+ clientLive: true,
4345
+ hasClientHalf: context.hasClientHalf
4346
+ }),
4347
+ ...note
4348
+ };
4349
+ }
4350
+ return {
4351
+ state: "failed",
4352
+ detail: `dsh-plugin-shop: dsh answered the ${context.operation} with "${change.application ?? "nothing"}", which this shop does not know how to read.`
4353
+ };
4354
+ }
4355
+ //#endregion
4356
+ //#region src/host/manager-runner.ts
4357
+ /**
4358
+ * One package operation through dsh's `pluginManager` service, tracked as the
4359
+ * same `RunningInstall` the CLI executor produces: design
4360
+ * 2026-09-26-plugin-manager-delegation, section 6. It runs in the same
4361
+ * per-profile queue as CLI commands, so the two paths are never concurrent in
4362
+ * one profile.
4363
+ */
4364
+ /** The first line of every record this runner writes. The shop's log panel
4365
+ * shows it and the 0.1.7 e2e asserts it, so a silent fall back to the CLI
4366
+ * fails a case instead of passing it. */
4367
+ const MECHANISM_PREFIX = "via dsh's plugin manager:";
4368
+ /** Routes the service's `plugin-manager/install-log` chunks to the record
4369
+ * whose request id they carry. A chunk for any other id is dropped. dsh
4370
+ * tags each chunk with the stream it came from; a non-string stream reads
4371
+ * as `stdout`, matching a harness that predates the tag. */
4372
+ var ManagerLogs = class {
4373
+ sinks = /* @__PURE__ */ new Map();
4374
+ open(requestId, sink) {
4375
+ this.sinks.set(requestId, sink);
4376
+ return () => {
4377
+ this.sinks.delete(requestId);
4378
+ };
4379
+ }
4380
+ chunk(requestId, text, stream) {
4381
+ if (typeof requestId !== "string" || typeof text !== "string") return;
4382
+ this.sinks.get(requestId)?.(text, typeof stream === "string" ? stream : "stdout");
4383
+ }
4384
+ };
4385
+ /** What every value this cannot describe reads as. */
4386
+ const UNREADABLE = "an error with no readable message";
4387
+ /** What a thrown value says about itself, and total: describing an error is
4388
+ * never itself the reason a settle fails. An `Error` gives its `message`,
4389
+ * anything else `String(value)`. A message that is not a string, or a
4390
+ * description that throws anywhere, reads as one fixed string: a Proxy whose
4391
+ * getPrototypeOf trap throws defeats `instanceof` itself, a `message` getter
4392
+ * can throw, and a null-prototype object has no conversion to a string. */
4393
+ const messageOf = (error) => {
4394
+ try {
4395
+ const said = error instanceof Error ? error.message : String(error);
4396
+ if (typeof said === "string") return said;
4397
+ } catch {}
4398
+ return UNREADABLE;
4399
+ };
4400
+ function startManagerOperation(options) {
4401
+ const { profile, requestId, mechanism, logs, run, cancel, outcome, alsoConfirm, prefetcher, spec, env, desktop = false } = options;
4402
+ const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
4403
+ const mechanismLine = `${MECHANISM_PREFIX} ${mechanism}`;
4404
+ const log = createBoundedLog();
4405
+ let state = "running";
4406
+ let settled = null;
4407
+ let streamed = false;
4408
+ const status = () => ({
4409
+ state,
4410
+ log: [mechanismLine, ...log.lines()],
4411
+ ...settled?.state === "done" && settled.activation !== void 0 ? { activation: settled.activation } : {},
4412
+ ...settled?.state === "done" && settled.restartReason !== void 0 ? { restartReason: settled.restartReason } : {},
4413
+ ...settled?.detail !== void 0 ? { detail: settled.detail } : {}
4414
+ });
4415
+ const append = (line) => {
4416
+ if (isTerminalInstallState(state)) return;
4417
+ log.push(line);
4418
+ };
4419
+ const streams = /* @__PURE__ */ new Map();
4420
+ const streamFor = (stream) => {
4421
+ let sink = streams.get(stream);
4422
+ if (sink === void 0) {
4423
+ sink = lineSink(append);
4424
+ streams.set(stream, sink);
4425
+ }
4426
+ return sink;
4427
+ };
4428
+ const flushStreams = () => {
4429
+ for (const sink of streams.values()) sink.flush();
4430
+ };
4431
+ const close = logs.open(requestId, (text, stream) => {
4432
+ streamed = true;
4433
+ streamFor(stream).write(Buffer.from(text, "utf8"));
4434
+ });
4435
+ const queued = inProfileQueue(profile, async () => {
4436
+ state = "running";
4437
+ let deadline;
4438
+ if (cancel !== void 0) deadline = setTimeout(() => {
4439
+ Promise.resolve().then(() => cancel(requestId)).catch(() => {});
4440
+ }, timeoutMs);
4441
+ let raw;
4442
+ try {
4443
+ raw = await run(requestId);
4444
+ } catch (error) {
4445
+ flushStreams();
4446
+ settled = {
4447
+ state: "failed",
4448
+ detail: `dsh-plugin-shop: dsh's plugin manager failed${thrownTail(messageOf(error), desktop)}`
4449
+ };
4450
+ state = "failed";
4451
+ return status();
4452
+ } finally {
4453
+ clearTimeout(deadline);
4454
+ }
4455
+ let result;
4456
+ try {
4457
+ flushStreams();
4458
+ if (!streamed) {
4459
+ for (const line of readChange(raw).output.split(/\r?\n/)) if (line !== "") append(line);
4460
+ }
4461
+ result = outcome(raw);
4462
+ } catch (error) {
4463
+ settled = {
4464
+ state: "failed",
4465
+ detail: `dsh-plugin-shop: the shop could not read dsh's answer: ${messageOf(error)}`
4466
+ };
4467
+ state = "failed";
4468
+ return status();
4469
+ }
4470
+ if (result.state === "done" && alsoConfirm !== void 0) try {
4471
+ const objection = alsoConfirm();
4472
+ if (objection !== null) result = {
4473
+ state: "failed",
4474
+ detail: objection
4475
+ };
4476
+ } catch (error) {
4477
+ result = {
4478
+ state: "failed",
4479
+ detail: `dsh-plugin-shop: the shop could not check what dsh installed: ${messageOf(error)}`
4480
+ };
4481
+ }
4482
+ settled = result;
4483
+ state = result.state;
4484
+ return status();
4485
+ });
4486
+ if ((queued.ahead > 0 && prefetcher !== void 0 && spec !== void 0 ? requestDownloadPhase({
4487
+ prefetcher,
4488
+ profile,
4489
+ spec,
4490
+ env,
4491
+ log: append
4492
+ }) : null)?.started === true && !isTerminalInstallState(state)) state = "downloading";
4493
+ const finished = queued.finished.finally(() => {
4494
+ close();
4495
+ if (prefetcher !== void 0 && spec !== void 0) prefetcher.release(profile, spec);
4496
+ });
4497
+ finished.catch(() => {});
4498
+ return {
4499
+ installId: requestId,
4500
+ status,
4501
+ finished
4502
+ };
4503
+ }
4504
+ //#endregion
2977
4505
  //#region src/host/index.ts
2978
4506
  /** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
2979
4507
  var __runInitializers = function(thisArg, initializers, value) {
@@ -3026,10 +4554,25 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
3026
4554
  * service and to set an override that the platform check, being the first gate
3027
4555
  * of the three, could never reach. */
3028
4556
  const RESTART_BLOCKED_DETAIL = {
4557
+ 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
4558
  windows: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change",
3030
4559
  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
4560
  "port-zero": "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
3032
4561
  };
4562
+ /** Why the shop changes nothing in the desktop profile, for every mutation
4563
+ * that spawns dsh's CLI: install, uninstall and self-update. dsh refuses
4564
+ * the profile before any of them would run (`isDesktopProfile`), and the
4565
+ * failure that surfaced instead read "pnpm failed in the profile". */
4566
+ 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";
4567
+ /** Why a switch through dsh's `pluginManager` waits for an operation this
4568
+ * gateway started: dsh holds its profile lock for a whole install or removal
4569
+ * (`change()` wraps pnpm) and makes any other change wait for it up to its
4570
+ * `lockWaitMs`, 120 s, so a switch clicked mid-install would spin for two
4571
+ * minutes and then fail on the lock. The check is F-5's, `hasRunningCommand`.
4572
+ * The shop's own row writer takes no dsh lock, so what it writes alone (the
4573
+ * CLI path's switch, and switching a deselected package off) is not
4574
+ * refused. */
4575
+ const switchWhileBusyDetail = (name) => `dsh-plugin-shop: an install, update or uninstall is still running in this profile, and dsh holds the profile until it ends; switch ${name} after it finishes.`;
3033
4576
  /** How many bytes a release tarball may be at the integrity check. The
3034
4577
  * registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
3035
4578
  * headroom, not a gate of its own. */
@@ -3087,6 +4630,29 @@ async function verifyTarballSha256(fetchTarball, url, recordedSha256, maxBytes =
3087
4630
  function ownDependencySpec(dependencies, name) {
3088
4631
  return Object.hasOwn(dependencies, name) ? dependencies[name] : void 0;
3089
4632
  }
4633
+ /**
4634
+ * Package names whose module this PROCESS may already hold in Node's module
4635
+ * cache (design 2026-09-26-market-borrowings §1.2).
4636
+ *
4637
+ * A hot mount can only deliver code the process has not imported yet. The hot
4638
+ * tree imports through the loader's `internal.import`, which is Node's own ESM
4639
+ * loader and caches a module by its URL, and an install rewrites a package's
4640
+ * files at the URL it already had (`nodeLinker: hoisted`). Once a name is in
4641
+ * here, a mount of it would re-run the cached module under whatever version is
4642
+ * now on disk.
4643
+ *
4644
+ * Module scope rather than a gateway field, because the cache it models is the
4645
+ * process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
4646
+ * must not forget what the process imported before it existed. Every gateway
4647
+ * seeds it with the profile's dependencies at construction (the boot
4648
+ * composition) and extends it with every name the hot path mounts. It extends
4649
+ * it again with every name one of its own installs through dsh's
4650
+ * `pluginManager` brought to the enable stage, and with the profile's
4651
+ * dependencies whenever that service reports an install: dsh's own Plugins
4652
+ * page installs through it, and the report names no package. Nothing is ever
4653
+ * removed: an uninstall disposes the fiber, not the module record.
4654
+ */
4655
+ const importedThisProcess = /* @__PURE__ */ new Set();
3090
4656
  /** Remote-only service exposing the shop Remote methods of §7.3.
3091
4657
  *
3092
4658
  * @typert service shop */
@@ -3234,6 +4800,17 @@ let ShopGateway = (() => {
3234
4800
  });
3235
4801
  }
3236
4802
  options = __runInitializers(this, _instanceExtraInitializers);
4803
+ /** The context this gateway was built with: the shop's own fiber. Inside an
4804
+ * RPC method `this.ctx` is NOT that — cordis hands a service out with `ctx`
4805
+ * rebound to the context that looked it up, so a call arriving over the
4806
+ * wire sees the caller's, the typert gateway's (measured on 0.1.5-rc.3 and
4807
+ * 0.1.7-rc.2). Anything whose PARENT matters is registered from here. The
4808
+ * hot tree above all: registered from the gateway's context it was owned by
4809
+ * the gateway's fiber rather than the shop's, was listed under the gateway's
4810
+ * loader entry, and — the part a reader saw — sat where dsh's client
4811
+ * registry never composed it, so a hot-installed browser half needed a
4812
+ * restart that a reload now does (design 2026-09-26-dsh-017-readiness). */
4813
+ home;
3237
4814
  /** The profile dsh installs into; discovered from this module's own
3238
4815
  * location when the caller does not supply one. */
3239
4816
  profile;
@@ -3253,6 +4830,11 @@ let ShopGateway = (() => {
3253
4830
  exit;
3254
4831
  restartExitDelayMs;
3255
4832
  restartParentPid;
4833
+ /** The restart this process committed, once it has: every later
4834
+ * `shop/restart` answers with it. A page that lost the first answer to an
4835
+ * HMR swap asks again (client/restart-monitor.ts), and inside the exit
4836
+ * delay that ask reaches this same process. */
4837
+ committedRestart = null;
3256
4838
  latestVersion;
3257
4839
  pinFs;
3258
4840
  allowRestart;
@@ -3269,6 +4851,11 @@ let ShopGateway = (() => {
3269
4851
  /** One pump for the whole gateway: batching is per profile and lives inside
3270
4852
  * it, so a second instance would race the first for the same store. */
3271
4853
  prefetcher;
4854
+ /** What this process may already have imported (see `importedThisProcess`). */
4855
+ imported;
4856
+ /** Routes the output dsh's `pluginManager` streams to the record whose
4857
+ * request id it carries (see the constructor). */
4858
+ managerLogs;
3272
4859
  /** The install gate runs against the last loaded snapshot, never a fresh
3273
4860
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
3274
4861
  /** Finished install records retained, so a poll sees the true terminal
@@ -3288,22 +4875,19 @@ let ShopGateway = (() => {
3288
4875
  /** The user's own `registry=` from `~/.npmrc`, read at most once per
3289
4876
  * gateway. A wrapper distinguishes a genuine `null` from an unread value. */
3290
4877
  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;
3301
- /** Install records, running and finished; a poll finds one here or reports not found. */
4878
+ /** The harness this process runs, read at most once per gateway: which dsh
4879
+ * is running cannot change while it runs, and the read imports a module. A
4880
+ * promise, so concurrent first calls share one read; `readRunningHarness`
4881
+ * never rejects, so there is no failure to retry. */
4882
+ harnessRead = null;
4883
+ /** Install records, running and finished, from the CLI and from dsh's
4884
+ * `pluginManager` alike; a poll finds one here or reports not found. */
3302
4885
  installs = /* @__PURE__ */ new Map();
3303
4886
  /** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
3304
4887
  installOrder = [];
3305
4888
  constructor(ctx, options = {}) {
3306
4889
  super(ctx, "shop");
4890
+ this.home = ctx;
3307
4891
  this.options = options;
3308
4892
  this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
3309
4893
  this.profileDir = options.profileDir;
@@ -3332,6 +4916,17 @@ let ShopGateway = (() => {
3332
4916
  this.ppid = options.ppid ?? process.ppid;
3333
4917
  this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
3334
4918
  this.prefetcher = options.prefetcher ?? createPrefetcher();
4919
+ this.imported = options.importedModules ?? importedThisProcess;
4920
+ this.recordDependenciesImported();
4921
+ this.managerLogs = new ManagerLogs();
4922
+ const on = this.ctx.on;
4923
+ on?.call(this.ctx, "plugin-manager/install-log", (payload) => {
4924
+ const chunk = payload;
4925
+ this.managerLogs.chunk(chunk?.requestId, chunk?.text, chunk?.stream);
4926
+ });
4927
+ on?.call(this.ctx, "plugin-manager/changed", (payload) => {
4928
+ if (payload?.reason === "install") this.recordDependenciesImported();
4929
+ });
3335
4930
  try {
3336
4931
  cleanHotDir(this.profileDirResolved());
3337
4932
  } catch {}
@@ -3347,7 +4942,7 @@ let ShopGateway = (() => {
3347
4942
  try {
3348
4943
  createPeerVersionCheck({
3349
4944
  ranges: this.options.peerRanges ?? ownPeerRanges(),
3350
- resolve: this.options.resolvePeerVersion ?? nodeVersionResolver(pathToFileURL(join(this.profileDirResolved(), "cordis.yml")).href),
4945
+ resolve: this.options.resolvePeerVersion ?? this.peerVersionResolver(this.profileAnchor()),
3351
4946
  warn: (message) => {
3352
4947
  const logger = this.ctx.logger;
3353
4948
  if (logger === void 0) console.warn(message);
@@ -3379,6 +4974,65 @@ let ShopGateway = (() => {
3379
4974
  if (this.profileDir !== void 0) return this.profileDir;
3380
4975
  return discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).dir;
3381
4976
  }
4977
+ /** Where every "what can this installation's plugins import?" question
4978
+ * resolves from: the profile's own Loader root, which is where the harness
4979
+ * resolves plugins from. One definition for the peer presence check and the
4980
+ * load-time self-check, so neither can drift onto a different notion of
4981
+ * "the installation". NOT where the running harness is read from: a copy
4982
+ * of `@deepseek-ai/dsh` a plugin's dependencies hoist into the profile is
4983
+ * importable from here and is not what runs (`harness.ts`). Throws when no
4984
+ * profile directory can be discovered and none is given. */
4985
+ profileAnchor(profileDir = this.profileDirResolved()) {
4986
+ return pathToFileURL(join(profileDir, "cordis.yml")).href;
4987
+ }
4988
+ /** dsh's `pluginPackages` service, when the running harness provides one
4989
+ * (0.1.7 and later), else null. Read on each use, like `pluginInventory`.
4990
+ * It is how such a harness serves its own packages to plugins, which no
4991
+ * walk of the profile's disk can see (design
4992
+ * 2026-09-01-harness-compatibility §11). */
4993
+ harnessPackages() {
4994
+ const service = this.ctx.get?.("pluginPackages");
4995
+ return typeof service?.packageOf === "function" ? service : null;
4996
+ }
4997
+ /** dsh's `pluginManager` service, when the running harness provides all of
4998
+ * it (0.1.7 and later), else null. Never cached across calls, like
4999
+ * `pluginPackages`, but read once per RPC, at its start, and passed down:
5000
+ * read again after an await, a service disposed in between would let a
5001
+ * desktop mutation past the desktop gate and onto the CLI path, which dsh
5002
+ * refuses for that profile. Design 2026-09-26-plugin-manager-delegation,
5003
+ * section 3.1. */
5004
+ pluginManager() {
5005
+ return asPluginManager(this.ctx.get?.("pluginManager"));
5006
+ }
5007
+ /** "Does this installation provide `spec`?", asked from `anchor`: the
5008
+ * harness's own resolution in front of the walk where the harness has one,
5009
+ * the walk alone where it has not. */
5010
+ peerResolver(anchor) {
5011
+ const walk = nodeResolver(anchor);
5012
+ const packages = this.harnessPackages();
5013
+ return packages === null ? walk : harnessPackageResolver(walk, packages, anchor);
5014
+ }
5015
+ /** `peerResolver`'s twin for the versions the load-time self-check reads. */
5016
+ peerVersionResolver(anchor) {
5017
+ const walk = nodeVersionResolver(anchor);
5018
+ const packages = this.harnessPackages();
5019
+ return packages === null ? walk : harnessPackageVersionResolver(walk, packages, anchor);
5020
+ }
5021
+ /** `profileDirResolved`, or null when no profile directory can be
5022
+ * discovered — for the reads that answer "cannot tell" rather than fail. */
5023
+ profileDirOrNone() {
5024
+ try {
5025
+ return this.profileDirResolved();
5026
+ } catch {
5027
+ return null;
5028
+ }
5029
+ }
5030
+ /** The harness this process runs, read once (see `harnessRead`). */
5031
+ runningHarness() {
5032
+ const read = this.options.readHarness ?? readRunningHarness;
5033
+ this.harnessRead ??= read(this.restartScript);
5034
+ return this.harnessRead;
5035
+ }
3382
5036
  /** The inventory, through the wire remote: an envelope `{ ok, value }` or
3383
5037
  * `{ ok: false, error }`. Each row is re-validated before trust (same
3384
5038
  * discipline as the rowConfig cast). */
@@ -3403,8 +5057,9 @@ let ShopGateway = (() => {
3403
5057
  * down. A disable can land while the entry's init is still in flight: the
3404
5058
  * options flip but the finishing init brings the fiber up anyway, and a
3405
5059
  * 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. */
5060
+ * The uninstall path is the one caller: an update used to swap its running
5061
+ * instance for a hot mount through here, and no longer mounts at all
5062
+ * (design 2026-09-26-market-borrowings §1). */
3408
5063
  /** The package's owned entry ids, or none when its bundle patch cannot be
3409
5064
  * read. For the paths where a live disable is an optimization and the
3410
5065
  * operation must succeed regardless; `setEnabled` reports the failure
@@ -3438,13 +5093,20 @@ let ShopGateway = (() => {
3438
5093
  * Through `ownDependencySpec` for the reason spelled out there: a bare index
3439
5094
  * read answers for `Object.prototype`, and `constructor` is a legal npm
3440
5095
  * 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.
5096
+ * weigh a function as the installed spec, and a phantom `isUpdate` would
5097
+ * refuse a hot mount to a package this process never imported.
3443
5098
  */
3444
5099
  installedSpecOf(name) {
3445
5100
  const dependencies = this.profileDependenciesOrNone();
3446
5101
  return dependencies === void 0 ? void 0 : ownDependencySpec(dependencies, name);
3447
5102
  }
5103
+ /** Add every dependency the profile manifest holds to the `imported`
5104
+ * record (see `importedThisProcess`): each is a package dsh's loader may
5105
+ * have loaded in this process, at boot or by an install since. An
5106
+ * unreadable manifest adds nothing. */
5107
+ recordDependenciesImported() {
5108
+ for (const name of Object.keys(this.profileDependenciesOrNone() ?? {})) this.imported.add(name);
5109
+ }
3448
5110
  ownedEntryIdsOrNone(packageName) {
3449
5111
  try {
3450
5112
  return ownedEntryIds({
@@ -3503,16 +5165,18 @@ let ShopGateway = (() => {
3503
5165
  }
3504
5166
  return allDown;
3505
5167
  }
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. */
5168
+ /** Enable or disable one installed plugin, hot (§8): the write sets the
5169
+ * `disabled` key of each owned entry's row in the user layer and changes
5170
+ * nothing else there (design 2026-09-26-market-borrowings §2) — the CLI's
5171
+ * watchUserPatches applies it through HMR. The shop's own row and the
5172
+ * framework's bundles are never toggleable: disabling the host chain would
5173
+ * break HMR itself. */
3511
5174
  async setEnabled(args) {
3512
5175
  if (args.name === "dsh-plugin-shop" || args.name.startsWith("@deepseek-ai/")) return {
3513
5176
  ok: false,
3514
5177
  detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
3515
5178
  };
5179
+ const manager = this.pluginManager();
3516
5180
  const profileDir = this.profileDirResolved();
3517
5181
  const manifest = readProfileManifest("dsh-plugin-shop", profileDir);
3518
5182
  if (!Object.hasOwn(manifest.dependencies ?? {}, args.name)) return {
@@ -3521,7 +5185,7 @@ let ShopGateway = (() => {
3521
5185
  };
3522
5186
  let owned;
3523
5187
  try {
3524
- owned = ownedEntryIds({
5188
+ owned = ownedEntries({
3525
5189
  profileDir,
3526
5190
  packageName: args.name
3527
5191
  });
@@ -3535,16 +5199,66 @@ let ShopGateway = (() => {
3535
5199
  ok: false,
3536
5200
  detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
3537
5201
  };
3538
- const ownedSet = new Set(owned);
3539
- if ((await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId)).length === 0) return {
5202
+ const selected = this.runningProfileBundles(profileDir);
5203
+ if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned, manager);
5204
+ const ownedSet = new Set(owned.map((entry) => entry.id));
5205
+ const live = (await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId));
5206
+ if (live.length === 0) return {
3540
5207
  ok: false,
3541
5208
  detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
3542
5209
  };
5210
+ if (manager !== null) {
5211
+ if (this.hasRunningCommand()) return {
5212
+ ok: false,
5213
+ detail: switchWhileBusyDetail(args.name)
5214
+ };
5215
+ let restart = false;
5216
+ let switched = 0;
5217
+ const failed = (detail) => {
5218
+ if (switched === 0) return {
5219
+ ok: false,
5220
+ detail
5221
+ };
5222
+ return {
5223
+ ok: false,
5224
+ detail: `${/[.!?]$/.test(detail) ? detail : `${detail}.`} ${switched} of its ${live.length} plugins had already switched ${args.enabled ? "on" : "off"}.`
5225
+ };
5226
+ };
5227
+ for (const entry of live) {
5228
+ let raw;
5229
+ try {
5230
+ raw = await manager.setPluginEnabled(entry.entryId, args.enabled);
5231
+ } catch (error) {
5232
+ return failed(`dsh-plugin-shop: dsh could not switch ${args.name}${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`);
5233
+ }
5234
+ const { change, failure } = this.readManagerAnswer(raw, {
5235
+ act: `switch ${args.name}`,
5236
+ request: `the switch for ${args.name}`,
5237
+ stays: args.enabled ? "off" : "on"
5238
+ });
5239
+ if (failure !== null) return failed(failure);
5240
+ switched += 1;
5241
+ if (change.application === "restart-required") restart = true;
5242
+ }
5243
+ if (restart) return {
5244
+ ok: true,
5245
+ activation: "restart"
5246
+ };
5247
+ return {
5248
+ ok: true,
5249
+ activation: activationOf({
5250
+ hostLive: true,
5251
+ clientLive: true,
5252
+ hasClientHalf: this.packageHasClientHalf(args.name)
5253
+ })
5254
+ };
5255
+ }
3543
5256
  setUserLayerRows({
3544
5257
  profileDir,
3545
- rows: owned.map((id) => ({
5258
+ rows: owned.map(({ id, name }) => ({
3546
5259
  id,
3547
- disabled: !args.enabled
5260
+ disabled: !args.enabled,
5261
+ ...name !== void 0 ? { name } : {}
3548
5262
  }))
3549
5263
  });
3550
5264
  return {
@@ -3556,6 +5270,119 @@ let ShopGateway = (() => {
3556
5270
  })
3557
5271
  };
3558
5272
  }
5273
+ /**
5274
+ * `setEnabled` for an installed package whose bundle the profile does not
5275
+ * select.
5276
+ *
5277
+ * Switching it off writes the plugin-level rows and nothing else: none of
5278
+ * it runs, and the rows keep it off if dsh selects the bundle again.
5279
+ * Switching it on selects the bundle through dsh's own `pluginManager`, the
5280
+ * writer behind the switch that deselected it, and then clears the
5281
+ * plugin-level rows as an ordinary enable does. A harness without that
5282
+ * service gets the way back in words instead. `dsh plugin add <name>` is
5283
+ * not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
5284
+ * float a registry package to `latest`.
5285
+ */
5286
+ async setDeselectedEnabled(name, enabled, profileDir, owned, manager) {
5287
+ const rows = owned.map(({ id, name: moduleName }) => ({
5288
+ id,
5289
+ disabled: !enabled,
5290
+ ...moduleName !== void 0 ? { name: moduleName } : {}
5291
+ }));
5292
+ if (!enabled) {
5293
+ setUserLayerRows({
5294
+ profileDir,
5295
+ rows
5296
+ });
5297
+ return {
5298
+ ok: true,
5299
+ activation: "live"
5300
+ };
5301
+ }
5302
+ if (manager === null) return {
5303
+ ok: false,
5304
+ 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.`
5305
+ };
5306
+ if (this.hasRunningCommand()) return {
5307
+ ok: false,
5308
+ detail: switchWhileBusyDetail(name)
5309
+ };
5310
+ let raw;
5311
+ try {
5312
+ raw = await manager.setBundleEnabled(name, true);
5313
+ } catch (error) {
5314
+ return {
5315
+ ok: false,
5316
+ detail: `dsh-plugin-shop: dsh could not select ${name} again${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`
5317
+ };
5318
+ }
5319
+ const { change, failure } = this.readManagerAnswer(raw, {
5320
+ act: `select ${name} again`,
5321
+ request: `the selection of ${name}`,
5322
+ stays: "off"
5323
+ });
5324
+ if (failure !== null) return {
5325
+ ok: false,
5326
+ detail: failure
5327
+ };
5328
+ setUserLayerRows({
5329
+ profileDir,
5330
+ rows
5331
+ });
5332
+ if (change.application === "restart-required") return {
5333
+ ok: true,
5334
+ activation: "restart"
5335
+ };
5336
+ return {
5337
+ ok: true,
5338
+ activation: activationOf({
5339
+ hostLive: true,
5340
+ clientLive: true,
5341
+ hasClientHalf: this.packageHasClientHalf(name)
5342
+ })
5343
+ };
5344
+ }
5345
+ /**
5346
+ * dsh's answer to a switch or a bundle selection, read the one way both
5347
+ * callers need it (R32): the change, scrubbed for a desktop reader, and the
5348
+ * detail that fails the request, or null when dsh applied it. `act` is what
5349
+ * was asked, as "switch <name>", `request` names the request itself, as
5350
+ * "the switch for <name>", and `stays` is the state the plugin keeps when
5351
+ * the request does not take effect. An application this shop does not know
5352
+ * fails rather than reading as applied: a later harness may answer
5353
+ * something new.
5354
+ *
5355
+ * `overridden` fails too (R39). dsh answers it when, after it wrote the row
5356
+ * and reloaded, the live entry still differs from the request
5357
+ * (dsh-plugin-manager 0.1.7-rc.2, setPluginEnabled), and its README names
5358
+ * what outranks the profile's own patch: home and invocation patches keep
5359
+ * their higher priority. The change is saved and the plugin runs as it
5360
+ * did, so a success would flip the client's switch over a plugin that did
5361
+ * not change, and that switch never re-reads the live state.
5362
+ * setBundleEnabled never answers it; a selection reads it the same way.
5363
+ */
5364
+ readManagerAnswer(raw, words) {
5365
+ const change = isDesktopProfile(this.profile) ? forDesktopReader(readChange(raw)) : readChange(raw);
5366
+ if (change.application === "failed" || change.application === "cancelled" || change.errorCode !== null) {
5367
+ const code = change.errorCode ?? change.application ?? "failed";
5368
+ return {
5369
+ change,
5370
+ failure: `dsh-plugin-shop: dsh ${code === "operation-error" ? "could not" : "refused to"} ${words.act} (${code})${codeReason(change, code)}`
5371
+ };
5372
+ }
5373
+ if (change.application === "overridden") return {
5374
+ change,
5375
+ failure: `dsh-plugin-shop: dsh saved ${words.request}, but a home or invocation patch, which outranks the profile's own, keeps it ${words.stays}.`
5376
+ };
5377
+ if (change.application === "applied" || change.application === "restart-required") return {
5378
+ change,
5379
+ failure: null
5380
+ };
5381
+ return {
5382
+ change,
5383
+ failure: `dsh-plugin-shop: dsh answered ${words.request} with "${change.application ?? "nothing"}", which this shop does not know how to read.`
5384
+ };
5385
+ }
3559
5386
  rowConfig() {
3560
5387
  if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
3561
5388
  catalogUrl: this.options.catalogUrl,
@@ -3635,11 +5462,16 @@ let ShopGateway = (() => {
3635
5462
  * static does. One ordered list, read by `restart()` before it commits and
3636
5463
  * by `version()` so the client can say the same thing up front.
3637
5464
  *
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.
5465
+ * The order is load-bearing for the copy a reader sees. The desktop
5466
+ * profile first: dsh itself refuses to launch it, so no platform,
5467
+ * supervisor or port could make a restart possible there. Then Windows,
5468
+ * because the platform check has no override and reporting the systemd one
5469
+ * there sends a Windows user to set `allowRestart: true`, which this gate
5470
+ * would still refuse.
3642
5471
  *
5472
+ * - `desktop`: dsh's CLI refuses `--profile desktop` ("managed exclusively
5473
+ * by the Electron application"), so the relaunch would exit at once and
5474
+ * leave nothing on the port; the app restarts it.
3643
5475
  * - `windows`: the handoff helper is a POSIX shell one-liner (restart.ts)
3644
5476
  * and there is no `sh` on Windows. That spawn fails ASYNCHRONOUSLY, so
3645
5477
  * committing would answer `ok: true`, exit this process, and leave nothing
@@ -3651,6 +5483,7 @@ let ShopGateway = (() => {
3651
5483
  * - `port-zero`: the OS hands the NEW process a fresh port the browser
3652
5484
  * cannot know, so a restart would strand the client on a dead origin. */
3653
5485
  staticRestartBlock() {
5486
+ if (isDesktopProfile(this.profile)) return "desktop";
3654
5487
  if (!this.restartPlatformSupported()) return "windows";
3655
5488
  if (detectSupervisor(this.env, { ppid: this.ppid }) === "systemd" && !this.allowRestartConfigured()) return "systemd";
3656
5489
  const portIndex = this.restartArgv.indexOf("--port");
@@ -3664,20 +5497,31 @@ let ShopGateway = (() => {
3664
5497
  /** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
3665
5498
  async catalog(args) {
3666
5499
  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
- };
5500
+ const harness = await this.runningHarness();
5501
+ const profileDir = this.profileDirOrNone();
5502
+ let incompatible = {};
5503
+ if (profileDir !== null) try {
5504
+ const resolve = this.options.resolvePeer ?? this.peerResolver(this.profileAnchor(profileDir));
5505
+ incompatible = incompatibilityMap(snapshot.entries, resolve);
5506
+ } catch {
5507
+ incompatible = {};
5508
+ }
5509
+ let incompatibleHarness;
5510
+ try {
5511
+ incompatibleHarness = compatibilityMap(snapshot.entries, {
5512
+ dshVersion: harness.dshVersion,
5513
+ profile: {
5514
+ name: this.profile,
5515
+ bundles: profileDir === null ? null : this.runningProfileBundles(profileDir)
5516
+ }
5517
+ }, harness.templates);
5518
+ } catch {
5519
+ incompatibleHarness = {};
3680
5520
  }
5521
+ for (const [key, peers] of Object.entries(this.refusedByHarness(snapshot.entries, harness, profileDir))) incompatibleHarness[key] = {
5522
+ ...incompatibleHarness[key],
5523
+ peers
5524
+ };
3681
5525
  return {
3682
5526
  schemaVersion: snapshot.schemaVersion,
3683
5527
  builtAt: snapshot.builtAt,
@@ -3686,15 +5530,57 @@ let ShopGateway = (() => {
3686
5530
  denied: snapshot.denied,
3687
5531
  notAShop: snapshot.notAShop ?? [],
3688
5532
  stars: snapshot.stars,
3689
- incompatible
5533
+ incompatible,
5534
+ incompatibleHarness
3690
5535
  };
3691
5536
  }
3692
5537
  /**
5538
+ * The installs the running dsh will refuse on their harness peers
5539
+ * (`peerVerdictsOf`), judged against the exemptions this profile holds —
5540
+ * read on every call like the other verdicts, because recording one is
5541
+ * exactly the event that must clear a card: the reader runs the command
5542
+ * the card shows, presses Refresh, and the install is allowed. Empty when
5543
+ * the running dsh has no such check (before 0.1.7) or no profile was
5544
+ * found, and when either read throws: a refusal nobody could establish is
5545
+ * never shown, least of all as a disabled button.
5546
+ */
5547
+ refusedByHarness(entries, harness, profileDir) {
5548
+ if (harness.peerCheck === null || profileDir === null) return {};
5549
+ try {
5550
+ return peerVerdictsOf(entries, harness.peerCheck, harness.peerCheck.exemptions(profileDir), this.profile);
5551
+ } catch {
5552
+ return {};
5553
+ }
5554
+ }
5555
+ /**
5556
+ * The bundles the profile at `profileDir` composes, or null when its
5557
+ * manifest cannot be read — which is no verdict on the profile half rather
5558
+ * than an accusation. `dsh.profile.bundles` is the same list
5559
+ * `discoverProfile` uses to recognize a profile directory, read through the
5560
+ * harness's own parser so a profile this shop was booted inside is read the
5561
+ * way dsh reads it.
5562
+ */
5563
+ runningProfileBundles(profileDir) {
5564
+ try {
5565
+ const bundles = readProfileManifest("dsh-plugin-shop", profileDir).dsh?.profile?.bundles;
5566
+ if (!Array.isArray(bundles) || !bundles.every((bundle) => typeof bundle === "string")) return null;
5567
+ return bundles;
5568
+ } catch {
5569
+ return null;
5570
+ }
5571
+ }
5572
+ /**
3693
5573
  * Install one cataloged version into the profile (§7.2). The rejection
3694
5574
  * paths run against this Host's snapshot before anything is spawned; only a
3695
5575
  * passing request reaches the executor.
3696
5576
  */
3697
5577
  async install(args) {
5578
+ const manager = this.pluginManager();
5579
+ if (isDesktopProfile(this.profile) && manager === null) return {
5580
+ ok: false,
5581
+ code: "desktop-profile",
5582
+ detail: DESKTOP_PROFILE_DETAIL
5583
+ };
3698
5584
  const snapshot = await this.snapshotNow();
3699
5585
  const installedSpec = this.installedSpecOf(args.name);
3700
5586
  const verdict = validateInstall(snapshot, args, installedSpec);
@@ -3722,61 +5608,160 @@ let ShopGateway = (() => {
3722
5608
  spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
3723
5609
  } else spec = `${args.name}@${args.version}`;
3724
5610
  const isUpdate = installedSpec !== void 0;
3725
- const priorEntryIds = isUpdate ? this.ownedEntryIdsOrNone(args.name) : [];
3726
- const priorClientHalf = isUpdate && this.packageHasClientHalf(args.name);
5611
+ const alreadyImported = isUpdate || this.imported.has(args.name);
5612
+ const harness = await this.runningHarness();
5613
+ if (manager !== null) {
5614
+ const running = this.startManagerInstall(manager, spec, `install ${spec}`, (raw) => {
5615
+ if (!isUpdate && readChange(raw).stage === "enable") this.imported.add(args.name);
5616
+ return managerOutcome(raw, this.outcomeContext(args.name, isUpdate ? "update" : "install", alreadyImported, this.packageHasClientHalf(args.name)));
5617
+ }, () => this.postInstallHazard(args.name, harness, " It is on disk: uninstall it from the shop to undo this install."));
5618
+ this.track(running);
5619
+ this.recordGithubPin(entry);
5620
+ return {
5621
+ ok: true,
5622
+ installId: running.installId,
5623
+ state: running.status().state
5624
+ };
5625
+ }
3727
5626
  const running = startInstall({
3728
5627
  profile: this.profile,
3729
5628
  spec,
3730
5629
  dshBin: this.dshBin,
3731
5630
  expectedName: args.name,
3732
5631
  prefetcher: this.prefetcher,
3733
- alsoConfirm: () => {
3734
- const clash = collidingEntryId({
3735
- profileDir: this.profileDirResolved(),
3736
- packageName: args.name,
3737
- dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
3738
- });
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.`;
3741
- },
5632
+ alsoConfirm: () => this.postInstallHazard(args.name, harness, ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`),
3742
5633
  afterDone: async () => {
5634
+ if (alreadyImported) return {
5635
+ activation: "restart",
5636
+ restartReason: "already-loaded"
5637
+ };
3743
5638
  const hot = this.hot ?? {
3744
5639
  mount: hotMount,
3745
5640
  unmount: hotUnmount
3746
5641
  };
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);
5642
+ this.imported.add(args.name);
5643
+ const result = await hot.mount({ plugin: (plugin, config) => this.home.plugin(plugin, config) }, this.profileDirResolved(), args.name);
3749
5644
  if (!result.ok) return {
3750
5645
  activation: "restart",
3751
5646
  ...result.reason !== null ? { restartReason: result.reason } : {}
3752
5647
  };
3753
- const activation = activationOf({
5648
+ return { activation: activationOf({
3754
5649
  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 };
5650
+ clientLive: true,
5651
+ hasClientHalf: this.packageHasClientHalf(args.name)
5652
+ }) };
3762
5653
  }
3763
5654
  });
3764
- if (entry.source === "github") {
3765
- const pins = readRepoPins(this.pinFs, this.pinsPath());
3766
- writeRepoPins(this.pinFs, this.pinsPath(), {
3767
- ...pins,
3768
- [identityKey(entry)]: entry.version
3769
- });
3770
- }
3771
- this.installs.set(running.installId, running);
3772
- this.installOrder.push(running.installId);
3773
- this.evictFinishedInstalls();
5655
+ this.recordGithubPin(entry);
5656
+ this.track(running);
3774
5657
  return {
3775
5658
  ok: true,
3776
5659
  installId: running.installId,
3777
5660
  state: running.status().state
3778
5661
  };
3779
5662
  }
5663
+ /**
5664
+ * What would stop the profile from starting now that `name`'s files are on
5665
+ * disk, or null: an entry id another installed package already declares,
5666
+ * or a bundle patch declaration this dsh cannot read. Neither is knowable
5667
+ * before the files land. The package stays on disk either way, so each
5668
+ * detail ends with `undo`, the sentence that says how to take the install
5669
+ * back on the path that made it.
5670
+ */
5671
+ postInstallHazard(name, harness, undo) {
5672
+ const clash = collidingEntryId({
5673
+ profileDir: this.profileDirResolved(),
5674
+ packageName: name,
5675
+ dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
5676
+ });
5677
+ if (clash !== null) return `dsh-plugin-shop: ${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;
5678
+ switch (patchDeclarationHazard(declaredBundlePatch({
5679
+ profileDir: this.profileDirResolved(),
5680
+ packageName: name
5681
+ }), harness.patchLists)) {
5682
+ case null: return null;
5683
+ case "list-unsupported": return `dsh-plugin-shop: ${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;
5684
+ case "malformed": return `dsh-plugin-shop: ${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;
5685
+ }
5686
+ }
5687
+ /** Remember a github entry's pinned commit: the manifest records only
5688
+ * `github:owner/slug`, so the pins file is how `installed()` reports
5689
+ * outdated honestly. A failed install leaves a pin behind, but the
5690
+ * manifest presence gate keeps it invisible. */
5691
+ recordGithubPin(entry) {
5692
+ if (entry.source !== "github") return;
5693
+ const pins = readRepoPins(this.pinFs, this.pinsPath());
5694
+ writeRepoPins(this.pinFs, this.pinsPath(), {
5695
+ ...pins,
5696
+ [identityKey(entry)]: entry.version
5697
+ });
5698
+ }
5699
+ /** Forget an uninstalled package's pins: its own, and its identity pin when
5700
+ * a catalog row matched the installed spec. Both uninstall paths call this
5701
+ * once their operation has started, so a stale pin never outlives the
5702
+ * uninstall in the shop's cache either way. The plugin manager path tracks
5703
+ * its record first, so a write here that throws cannot hide an operation
5704
+ * already queued. */
5705
+ forgetPins(name, installedEntry) {
5706
+ const pins = readRepoPins(this.pinFs, this.pinsPath());
5707
+ const stalePins = [name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
5708
+ let forgot = false;
5709
+ for (const key of stalePins) {
5710
+ if (pins[key] === void 0) continue;
5711
+ delete pins[key];
5712
+ forgot = true;
5713
+ }
5714
+ if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
5715
+ }
5716
+ /** What `managerOutcome` reads dsh's answer against. The gateway's own
5717
+ * facts are set here once, for every operation through the service: the
5718
+ * profile, whether it is the desktop one, and the deadline a timeout
5719
+ * detail names. */
5720
+ outcomeContext(name, operation, alreadyImported, hasClientHalf) {
5721
+ return {
5722
+ profile: this.profile,
5723
+ name,
5724
+ operation,
5725
+ alreadyImported,
5726
+ hasClientHalf,
5727
+ desktop: isDesktopProfile(this.profile),
5728
+ timeoutMs: INSTALL_TIMEOUT_MS
5729
+ };
5730
+ }
5731
+ /**
5732
+ * One install-shaped operation through dsh's `pluginManager` (design
5733
+ * 2026-09-26-plugin-manager-delegation, section 6): `installBundle(spec)`
5734
+ * under the record's own id as its request id, so the output dsh streams
5735
+ * for it reaches this record, in the profile's queue with the download
5736
+ * phase the CLI path has. At the shop's deadline dsh is asked to cancel it;
5737
+ * a service that offers no `cancelInstall` gets no deadline, and its record
5738
+ * settles when dsh answers. `install` and `updateStart` differ only in the
5739
+ * mechanism line and in how they read the answer.
5740
+ */
5741
+ startManagerInstall(manager, spec, mechanism, outcome, alsoConfirm) {
5742
+ gateOperand(spec);
5743
+ const cancel = manager.cancelInstall?.bind(manager);
5744
+ return startManagerOperation({
5745
+ profile: this.profile,
5746
+ requestId: randomUUID(),
5747
+ mechanism,
5748
+ logs: this.managerLogs,
5749
+ run: (requestId) => manager.installBundle(spec, { requestId }),
5750
+ ...cancel !== void 0 ? { cancel } : {},
5751
+ outcome,
5752
+ ...alsoConfirm !== void 0 ? { alsoConfirm } : {},
5753
+ prefetcher: this.prefetcher,
5754
+ spec,
5755
+ desktop: isDesktopProfile(this.profile)
5756
+ });
5757
+ }
5758
+ /** Keep a started record where `installStatus`, the restart gate and
5759
+ * eviction find it, then bound the finished ones. */
5760
+ track(running) {
5761
+ this.installs.set(running.installId, running);
5762
+ this.installOrder.push(running.installId);
5763
+ this.evictFinishedInstalls();
5764
+ }
3780
5765
  /** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
3781
5766
  * oldest finished ones (insertion order, oldest first). Live records —
3782
5767
  * running AND queued — are never evicted; an id absent from the map reports
@@ -3844,10 +5829,13 @@ let ShopGateway = (() => {
3844
5829
  for (const entry of await this.listInventory()) live.set(entry.entryId, entry.enabled);
3845
5830
  haveInventory = true;
3846
5831
  } 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). */
5832
+ const selected = this.runningProfileBundles(this.profileDirResolved());
5833
+ /** A package is enabled when the profile selects its bundle and every
5834
+ * entry it owns and that is live is enabled. Keyed by entry id, never by
5835
+ * module name: the entry a package inserts may mount a different
5836
+ * package's module (see ownedEntryIds). */
3850
5837
  const enabledOf = (name) => {
5838
+ if (selected !== null && !selected.includes(name)) return false;
3851
5839
  if (!haveInventory) return true;
3852
5840
  let owned;
3853
5841
  try {
@@ -3914,6 +5902,11 @@ let ShopGateway = (() => {
3914
5902
  * dependencies the shop does not manage (the base bundle, the shop
3915
5903
  * itself). The same install records/polling serve the client. */
3916
5904
  async uninstall(args) {
5905
+ const manager = this.pluginManager();
5906
+ if (isDesktopProfile(this.profile) && manager === null) return {
5907
+ ok: false,
5908
+ detail: DESKTOP_PROFILE_DETAIL
5909
+ };
3917
5910
  const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
3918
5911
  if (named.length === 0) return {
3919
5912
  ok: false,
@@ -3927,6 +5920,23 @@ let ShopGateway = (() => {
3927
5920
  const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
3928
5921
  const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
3929
5922
  const hadClientHalf = this.packageHasClientHalf(args.name);
5923
+ if (manager !== null) {
5924
+ const running = startManagerOperation({
5925
+ profile: this.profile,
5926
+ requestId: randomUUID(),
5927
+ mechanism: `remove ${args.name}`,
5928
+ logs: this.managerLogs,
5929
+ run: () => manager.removeBundle(args.name),
5930
+ outcome: (raw) => managerOutcome(raw, this.outcomeContext(args.name, "uninstall", false, hadClientHalf)),
5931
+ desktop: isDesktopProfile(this.profile)
5932
+ });
5933
+ this.track(running);
5934
+ this.forgetPins(args.name, installedEntry);
5935
+ return {
5936
+ ok: true,
5937
+ installId: running.installId
5938
+ };
5939
+ }
3930
5940
  const running = startUninstall({
3931
5941
  profile: this.profile,
3932
5942
  name: args.name,
@@ -3943,18 +5953,8 @@ let ShopGateway = (() => {
3943
5953
  }) };
3944
5954
  }
3945
5955
  });
3946
- const pins = readRepoPins(this.pinFs, this.pinsPath());
3947
- const stalePins = [args.name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
3948
- let forgot = false;
3949
- for (const key of stalePins) {
3950
- if (pins[key] === void 0) continue;
3951
- delete pins[key];
3952
- forgot = true;
3953
- }
3954
- if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
3955
- this.installs.set(running.installId, running);
3956
- this.installOrder.push(running.installId);
3957
- this.evictFinishedInstalls();
5956
+ this.forgetPins(args.name, installedEntry);
5957
+ this.track(running);
3958
5958
  return {
3959
5959
  ok: true,
3960
5960
  installId: running.installId
@@ -3966,6 +5966,10 @@ let ShopGateway = (() => {
3966
5966
  * response is out. The browser monitors the origin and refreshes when the
3967
5967
  * new server answers. Refusals are issued before anything is torn down. */
3968
5968
  async restart() {
5969
+ if (this.committedRestart !== null) return {
5970
+ ok: true,
5971
+ logFile: this.committedRestart.logFile
5972
+ };
3969
5973
  if (this.hasRunningCommand()) return {
3970
5974
  ok: false,
3971
5975
  detail: "dsh-plugin-shop: an install is still running in this profile; a restart now would boot the new dsh against a half-written profile. Wait for it to finish and try again."
@@ -3975,6 +5979,7 @@ let ShopGateway = (() => {
3975
5979
  ok: false,
3976
5980
  detail: RESTART_BLOCKED_DETAIL[blocked]
3977
5981
  };
5982
+ let logFile;
3978
5983
  try {
3979
5984
  const { cacheDir } = this.rowConfig();
3980
5985
  const { command, args } = restartCommand({
@@ -3984,11 +5989,12 @@ let ShopGateway = (() => {
3984
5989
  execArgv: process.execArgv,
3985
5990
  script: this.restartScript
3986
5991
  });
5992
+ logFile = join(cacheDir, "restart.log");
3987
5993
  startRestart({
3988
5994
  command,
3989
5995
  args,
3990
5996
  parentPid: this.restartParentPid,
3991
- logFile: join(cacheDir, "restart.log"),
5997
+ logFile,
3992
5998
  env: process.env
3993
5999
  });
3994
6000
  } catch (error) {
@@ -3997,42 +6003,82 @@ let ShopGateway = (() => {
3997
6003
  detail: `dsh-plugin-shop: restart could not be started: ${error.message}`
3998
6004
  };
3999
6005
  }
6006
+ this.committedRestart = { logFile };
4000
6007
  setTimeout(() => this.exit(0), this.restartExitDelayMs);
4001
- return { ok: true };
6008
+ return {
6009
+ ok: true,
6010
+ logFile
6011
+ };
4002
6012
  }
4003
6013
  /** The shop's own version and whether npm has a newer one (§7.3). The
4004
6014
  * check is advisory: a registry that cannot answer leaves `latest` null
4005
6015
  * and the client shows the version alone. `installed` is the RUNNING
4006
6016
  * version (own-version.ts), not the manifest's range spec. */
4007
6017
  async version() {
4008
- const installed = ownVersion();
6018
+ const installed = this.options.runningVersion ?? runningVersion();
4009
6019
  const latest = await this.latestVersion();
4010
6020
  return {
4011
6021
  installed,
4012
6022
  latest,
4013
6023
  outdated: latest !== null && lt(installed, latest),
4014
- restartBlocked: this.staticRestartBlock()
6024
+ restartBlocked: this.staticRestartBlock(),
6025
+ pendingVersion: this.pendingVersion(installed)
4015
6026
  };
4016
6027
  }
6028
+ /**
6029
+ * The version of the shop the profile has installed, when it is not the
6030
+ * one this process runs; null when they match or the copy cannot be read.
6031
+ *
6032
+ * Read from the profile's own copy — the directory `dsh plugin add` writes
6033
+ * and the next boot loads — rather than from the file beside this module.
6034
+ * For the hoisted install those are one directory, but a `link:` install
6035
+ * runs from its link target, which an update replaces in the profile and
6036
+ * never touches at the target, so reading beside the module would never see
6037
+ * the update land.
6038
+ *
6039
+ * Unreadable is null, never a throw: the version check is advisory, and a
6040
+ * missing field would tell the client this host predates it.
6041
+ */
6042
+ pendingVersion(running) {
6043
+ try {
6044
+ const manifest = JSON.parse(readFileSync(join(this.profileDirResolved(), "node_modules", "dsh-plugin-shop", "package.json"), "utf8"));
6045
+ return typeof manifest.version === "string" && manifest.version !== running ? manifest.version : null;
6046
+ } catch {
6047
+ return null;
6048
+ }
6049
+ }
4017
6050
  /** Update the shop itself to a published version (§7.3): the explicit pin
4018
6051
  * is the only install form that bypasses pnpm's release cooldown. The
4019
6052
  * version is re-validated as plain semver at the boundary — the spec
4020
6053
  * `dsh-plugin-shop@<version>` is built here, never from the wire. */
4021
6054
  async updateStart(args) {
6055
+ const manager = this.pluginManager();
6056
+ if (isDesktopProfile(this.profile) && manager === null) return {
6057
+ ok: false,
6058
+ detail: DESKTOP_PROFILE_DETAIL
6059
+ };
4022
6060
  if (valid(args.version) === null) return {
4023
6061
  ok: false,
4024
6062
  detail: `dsh-plugin-shop: ${args.version} is not a valid version`
4025
6063
  };
6064
+ const spec = `dsh-plugin-shop@${args.version}`;
6065
+ if (manager !== null) {
6066
+ const running = this.startManagerInstall(manager, spec, `update ${spec}`, (raw) => managerOutcome(raw, this.outcomeContext("dsh-plugin-shop", "update", true, true)));
6067
+ this.track(running);
6068
+ return {
6069
+ ok: true,
6070
+ installId: running.installId,
6071
+ state: running.status().state
6072
+ };
6073
+ }
4026
6074
  const running = startInstall({
4027
6075
  profile: this.profile,
4028
- spec: `dsh-plugin-shop@${args.version}`,
6076
+ spec,
4029
6077
  dshBin: this.dshBin,
4030
6078
  expectedName: "dsh-plugin-shop",
4031
6079
  prefetcher: this.prefetcher
4032
6080
  });
4033
- this.installs.set(running.installId, running);
4034
- this.installOrder.push(running.installId);
4035
- this.evictFinishedInstalls();
6081
+ this.track(running);
4036
6082
  return {
4037
6083
  ok: true,
4038
6084
  installId: running.installId,