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

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
@@ -1509,6 +1509,35 @@ function chain(profile, task) {
1509
1509
  profileQueues.set(profile, next.catch(() => {}));
1510
1510
  return next;
1511
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
+ }
1512
1541
  /**
1513
1542
  * Why a zero-exit install left `dsh.profile.bundles` without the entry.
1514
1543
  *
@@ -1617,7 +1646,8 @@ const FAILURE_LOG_NOISE = [
1617
1646
  ];
1618
1647
  /** The line dsh 0.1.7 opens a refused install with (dsh-plugin-manager's
1619
1648
  * `rejected`), and the exemption command its CLI prints after it, one per
1620
- * refused package (`runPlugin`). */
1649
+ * refused package (`runPlugin`). The opener is exported for the plugin
1650
+ * manager's answer, whose output carries the same block. */
1621
1651
  const REFUSAL_OPENER = "dsh: installation rejected: ";
1622
1652
  const REFUSAL_COMMAND = /^dsh: to accept the risk, run: dsh plugin --profile .+ allow-version (.+)@([^@\s]+) --dsh-version (\S+) --accept-risk$/;
1623
1653
  const REFUSAL_FAILED = "dsh: plugin command failed";
@@ -1716,8 +1746,7 @@ function nativeAllocationAbortDetail(usable) {
1716
1746
  * Scanning from the end: a pnpm error code first, then any thrown error, then
1717
1747
  * the last line that is not noise.
1718
1748
  */
1719
- function installFailureDetail(profile, log) {
1720
- 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`) {
1721
1750
  const usable = log.map((line) => line.replace(/\r+$/, "")).filter((line) => !FAILURE_LOG_NOISE.some((rx) => rx.test(line)));
1722
1751
  const refusal = refusalDetail(profile, usable);
1723
1752
  if (refusal !== null) return refusal;
@@ -1825,6 +1854,23 @@ function dshScript() {
1825
1854
  * the spawned command line is the one we add ourselves, after this gate. */
1826
1855
  const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
1827
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
+ /**
1828
1874
  * The operand as the downstream dsh must receive it for pnpm to see it whole.
1829
1875
  *
1830
1876
  * dsh spawns pnpm with `shell: process.platform === 'win32'`
@@ -1862,6 +1908,32 @@ function shellSafeTarget(target, platform) {
1862
1908
  if (platform !== "win32" || !target.includes("&")) return target;
1863
1909
  return `"${target}"`;
1864
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
+ }
1865
1937
  /** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
1866
1938
  * Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
1867
1939
  * (§10). The shop never passes build-script flags: `allowBuilds` stays the
@@ -1878,14 +1950,11 @@ function shellSafeTarget(target, platform) {
1878
1950
  * package IS installed; it reports `done` with the restart fallback. */
1879
1951
  function spawnPluginCli(options) {
1880
1952
  const { profile, argv, dshBin, env, platform = process.platform, beforeSpawn, prefetcher, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
1881
- const target = argv[1];
1882
- if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
1883
- 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]);
1884
1954
  const spawnArgv = [...argv];
1885
1955
  spawnArgv[1] = shellSafeTarget(target, platform);
1886
1956
  const installId = randomUUID();
1887
- const log = [];
1888
- let logBytes = 0;
1957
+ const log = createBoundedLog();
1889
1958
  const ahead = enterQueue(profile);
1890
1959
  let state = "running";
1891
1960
  let activationOnDone = "restart";
@@ -1893,7 +1962,7 @@ function spawnPluginCli(options) {
1893
1962
  let detail;
1894
1963
  const status = () => ({
1895
1964
  state,
1896
- log: [...log],
1965
+ log: log.lines(),
1897
1966
  ...state === "done" ? {
1898
1967
  activation: activationOnDone,
1899
1968
  ...restartReason !== void 0 ? { restartReason } : {}
@@ -1903,11 +1972,6 @@ function spawnPluginCli(options) {
1903
1972
  const append = (line) => {
1904
1973
  if (isTerminalInstallState(state)) return;
1905
1974
  log.push(line);
1906
- logBytes += Buffer.byteLength(line);
1907
- while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
1908
- const oldest = log.shift();
1909
- if (oldest !== void 0) logBytes -= Buffer.byteLength(oldest);
1910
- }
1911
1975
  onStatus?.(status());
1912
1976
  };
1913
1977
  const failToStart = (error) => {
@@ -1917,19 +1981,14 @@ function spawnPluginCli(options) {
1917
1981
  return status();
1918
1982
  };
1919
1983
  let prefetch = null;
1920
- if (ahead > 0 && prefetcher !== void 0) try {
1921
- prefetch = prefetcher.request({
1922
- profile,
1923
- spec: target,
1924
- cwd: resolveProfileDir(profile, env?.DSH_HOME),
1925
- env,
1926
- log: append
1927
- });
1928
- } catch (error) {
1929
- append(`dsh-plugin-shop: the download phase could not start — ${error.message}`);
1930
- }
1984
+ if (ahead > 0 && prefetcher !== void 0) prefetch = requestDownloadPhase({
1985
+ prefetcher,
1986
+ profile,
1987
+ spec: target,
1988
+ env,
1989
+ log: append
1990
+ });
1931
1991
  if (prefetch?.started === true) state = "downloading";
1932
- 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");
1933
1992
  const finished = chain(profile, () => new Promise((resolve) => {
1934
1993
  state = "running";
1935
1994
  onStatus?.(status());
@@ -1998,7 +2057,7 @@ function spawnPluginCli(options) {
1998
2057
  } else state = "done";
1999
2058
  } else {
2000
2059
  state = "failed";
2001
- detail = installFailureDetail(profile, log);
2060
+ detail = installFailureDetail(profile, log.lines());
2002
2061
  }
2003
2062
  onStatus?.(status());
2004
2063
  resolve(status());
@@ -3929,6 +3988,520 @@ async function readRunningHarness(script) {
3929
3988
  }
3930
3989
  }
3931
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
3932
4505
  //#region src/host/index.ts
3933
4506
  /** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
3934
4507
  var __runInitializers = function(thisArg, initializers, value) {
@@ -3991,6 +4564,15 @@ const RESTART_BLOCKED_DETAIL = {
3991
4564
  * the profile before any of them would run (`isDesktopProfile`), and the
3992
4565
  * failure that surfaced instead read "pnpm failed in the profile". */
3993
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.`;
3994
4576
  /** How many bytes a release tarball may be at the integrity check. The
3995
4577
  * registry already refuses to publish a tarball over 32 MiB, so 64 MiB is
3996
4578
  * headroom, not a gate of its own. */
@@ -4063,8 +4645,12 @@ function ownDependencySpec(dependencies, name) {
4063
4645
  * process's: a gateway rebuilt inside a running dsh — a restarted shop fiber —
4064
4646
  * must not forget what the process imported before it existed. Every gateway
4065
4647
  * seeds it with the profile's dependencies at construction (the boot
4066
- * composition) and extends it with every name the hot path mounts. Nothing is
4067
- * ever removed: an uninstall disposes the fiber, not the module record.
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.
4068
4654
  */
4069
4655
  const importedThisProcess = /* @__PURE__ */ new Set();
4070
4656
  /** Remote-only service exposing the shop Remote methods of §7.3.
@@ -4244,6 +4830,11 @@ let ShopGateway = (() => {
4244
4830
  exit;
4245
4831
  restartExitDelayMs;
4246
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;
4247
4838
  latestVersion;
4248
4839
  pinFs;
4249
4840
  allowRestart;
@@ -4262,6 +4853,9 @@ let ShopGateway = (() => {
4262
4853
  prefetcher;
4263
4854
  /** What this process may already have imported (see `importedThisProcess`). */
4264
4855
  imported;
4856
+ /** Routes the output dsh's `pluginManager` streams to the record whose
4857
+ * request id it carries (see the constructor). */
4858
+ managerLogs;
4265
4859
  /** The install gate runs against the last loaded snapshot, never a fresh
4266
4860
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
4267
4861
  /** Finished install records retained, so a poll sees the true terminal
@@ -4286,7 +4880,8 @@ let ShopGateway = (() => {
4286
4880
  * promise, so concurrent first calls share one read; `readRunningHarness`
4287
4881
  * never rejects, so there is no failure to retry. */
4288
4882
  harnessRead = null;
4289
- /** Install records, running and finished; a poll finds one here or reports not found. */
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. */
4290
4885
  installs = /* @__PURE__ */ new Map();
4291
4886
  /** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
4292
4887
  installOrder = [];
@@ -4322,7 +4917,16 @@ let ShopGateway = (() => {
4322
4917
  this.fetchTarball = options.fetchTarball ?? ((url) => fetch(url));
4323
4918
  this.prefetcher = options.prefetcher ?? createPrefetcher();
4324
4919
  this.imported = options.importedModules ?? importedThisProcess;
4325
- for (const name of Object.keys(this.profileDependenciesOrNone() ?? {})) this.imported.add(name);
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
+ });
4326
4930
  try {
4327
4931
  cleanHotDir(this.profileDirResolved());
4328
4932
  } catch {}
@@ -4390,14 +4994,15 @@ let ShopGateway = (() => {
4390
4994
  const service = this.ctx.get?.("pluginPackages");
4391
4995
  return typeof service?.packageOf === "function" ? service : null;
4392
4996
  }
4393
- /** dsh's `pluginManager` service, when the running harness provides one
4394
- * (0.1.7 and later), else null. Read on each use, like `pluginPackages`.
4395
- * It is the only way back for a bundle its Plugins page deselected: the
4396
- * 0.1.7 CLI does not select an installed package again on `add`, while
4397
- * 0.1.5's does (both measured 2026-09-27). */
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. */
4398
5004
  pluginManager() {
4399
- const service = this.ctx.get?.("pluginManager");
4400
- return typeof service?.setBundleEnabled === "function" ? service : null;
5005
+ return asPluginManager(this.ctx.get?.("pluginManager"));
4401
5006
  }
4402
5007
  /** "Does this installation provide `spec`?", asked from `anchor`: the
4403
5008
  * harness's own resolution in front of the walk where the harness has one,
@@ -4495,6 +5100,13 @@ let ShopGateway = (() => {
4495
5100
  const dependencies = this.profileDependenciesOrNone();
4496
5101
  return dependencies === void 0 ? void 0 : ownDependencySpec(dependencies, name);
4497
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
+ }
4498
5110
  ownedEntryIdsOrNone(packageName) {
4499
5111
  try {
4500
5112
  return ownedEntryIds({
@@ -4564,6 +5176,7 @@ let ShopGateway = (() => {
4564
5176
  ok: false,
4565
5177
  detail: `dsh-plugin-shop: ${args.name} is part of the harness chain and cannot be toggled from the shop`
4566
5178
  };
5179
+ const manager = this.pluginManager();
4567
5180
  const profileDir = this.profileDirResolved();
4568
5181
  const manifest = readProfileManifest("dsh-plugin-shop", profileDir);
4569
5182
  if (!Object.hasOwn(manifest.dependencies ?? {}, args.name)) return {
@@ -4587,12 +5200,59 @@ let ShopGateway = (() => {
4587
5200
  detail: `dsh-plugin-shop: ${args.name} contributes no plugin entries, so there is nothing to enable or disable`
4588
5201
  };
4589
5202
  const selected = this.runningProfileBundles(profileDir);
4590
- if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned);
5203
+ if (selected !== null && !selected.includes(args.name)) return this.setDeselectedEnabled(args.name, args.enabled, profileDir, owned, manager);
4591
5204
  const ownedSet = new Set(owned.map((entry) => entry.id));
4592
- if ((await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId)).length === 0) return {
5205
+ const live = (await this.listInventory()).filter((entry) => ownsEntryId(ownedSet, entry.entryId));
5206
+ if (live.length === 0) return {
4593
5207
  ok: false,
4594
5208
  detail: `dsh-plugin-shop: ${args.name} is installed but its entries are not in the running plugin tree; restart dsh to compose them`
4595
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
+ }
4596
5256
  setUserLayerRows({
4597
5257
  profileDir,
4598
5258
  rows: owned.map(({ id, name }) => ({
@@ -4623,7 +5283,7 @@ let ShopGateway = (() => {
4623
5283
  * not printed: it selects the bundle again only on 0.1.5, and it lets pnpm
4624
5284
  * float a registry package to `latest`.
4625
5285
  */
4626
- async setDeselectedEnabled(name, enabled, profileDir, owned) {
5286
+ async setDeselectedEnabled(name, enabled, profileDir, owned, manager) {
4627
5287
  const rows = owned.map(({ id, name: moduleName }) => ({
4628
5288
  id,
4629
5289
  disabled: !enabled,
@@ -4639,23 +5299,31 @@ let ShopGateway = (() => {
4639
5299
  activation: "live"
4640
5300
  };
4641
5301
  }
4642
- const manager = this.pluginManager();
4643
5302
  if (manager === null) return {
4644
5303
  ok: false,
4645
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.`
4646
5305
  };
4647
- let change;
5306
+ if (this.hasRunningCommand()) return {
5307
+ ok: false,
5308
+ detail: switchWhileBusyDetail(name)
5309
+ };
5310
+ let raw;
4648
5311
  try {
4649
- change = await manager.setBundleEnabled(name, true);
5312
+ raw = await manager.setBundleEnabled(name, true);
4650
5313
  } catch (error) {
4651
5314
  return {
4652
5315
  ok: false,
4653
- detail: `dsh-plugin-shop: dsh could not select ${name} again: ${String(error)}`
5316
+ detail: `dsh-plugin-shop: dsh could not select ${name} again${thrownTail(messageOf(error), isDesktopProfile(this.profile))}`
4654
5317
  };
4655
5318
  }
4656
- if (change.application === "failed" || change.application === "cancelled" || change.error !== void 0) return {
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 {
4657
5325
  ok: false,
4658
- detail: `dsh-plugin-shop: dsh refused to select ${name} again (${typeof change.error?.code === "string" ? change.error.code : String(change.application)})${typeof change.error?.diagnostic === "string" ? `: ${change.error.diagnostic}` : ""}`
5326
+ detail: failure
4659
5327
  };
4660
5328
  setUserLayerRows({
4661
5329
  profileDir,
@@ -4674,6 +5342,47 @@ let ShopGateway = (() => {
4674
5342
  })
4675
5343
  };
4676
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
+ }
4677
5386
  rowConfig() {
4678
5387
  if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
4679
5388
  catalogUrl: this.options.catalogUrl,
@@ -4866,7 +5575,8 @@ let ShopGateway = (() => {
4866
5575
  * passing request reaches the executor.
4867
5576
  */
4868
5577
  async install(args) {
4869
- if (isDesktopProfile(this.profile)) return {
5578
+ const manager = this.pluginManager();
5579
+ if (isDesktopProfile(this.profile) && manager === null) return {
4870
5580
  ok: false,
4871
5581
  code: "desktop-profile",
4872
5582
  detail: DESKTOP_PROFILE_DETAIL
@@ -4897,31 +5607,29 @@ let ShopGateway = (() => {
4897
5607
  };
4898
5608
  spec = `github:${entry.repo}#${args.version}${entry.subdir !== void 0 ? `&path:${entry.subdir}` : ""}`;
4899
5609
  } else spec = `${args.name}@${args.version}`;
4900
- const alreadyImported = installedSpec !== void 0 || this.imported.has(args.name);
5610
+ const isUpdate = installedSpec !== void 0;
5611
+ const alreadyImported = isUpdate || this.imported.has(args.name);
4901
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
+ }
4902
5626
  const running = startInstall({
4903
5627
  profile: this.profile,
4904
5628
  spec,
4905
5629
  dshBin: this.dshBin,
4906
5630
  expectedName: args.name,
4907
5631
  prefetcher: this.prefetcher,
4908
- alsoConfirm: () => {
4909
- const clash = collidingEntryId({
4910
- profileDir: this.profileDirResolved(),
4911
- packageName: args.name,
4912
- dependencies: Object.keys(this.profileDependenciesOrNone() ?? {})
4913
- });
4914
- const undo = ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`;
4915
- if (clash !== null) return `dsh-plugin-shop: ${args.name} declares the loader entry id "${clash.id}", which ${clash.holder} already declares. dsh refuses to load a plugin tree holding a duplicate entry id, so the profile would not start.` + undo;
4916
- switch (patchDeclarationHazard(declaredBundlePatch({
4917
- profileDir: this.profileDirResolved(),
4918
- packageName: args.name
4919
- }), harness.patchLists)) {
4920
- case null: return null;
4921
- case "list-unsupported": return `dsh-plugin-shop: ${args.name} lists its bundle patch as several files, which dsh reads from 0.1.7 on; this dsh${harness.dshVersion === null ? "" : ` (${harness.dshVersion})`} reads one and would not start with it installed.` + undo;
4922
- case "malformed": return `dsh-plugin-shop: ${args.name} declares its bundle patch as neither a file path nor a list of them, which dsh refuses to load, so the profile would not start.` + undo;
4923
- }
4924
- },
5632
+ alsoConfirm: () => this.postInstallHazard(args.name, harness, ` It is on disk: run \`dsh plugin --profile ${this.profile} remove ${args.name}\` to undo this install.`),
4925
5633
  afterDone: async () => {
4926
5634
  if (alreadyImported) return {
4927
5635
  activation: "restart",
@@ -4944,22 +5652,116 @@ let ShopGateway = (() => {
4944
5652
  }) };
4945
5653
  }
4946
5654
  });
4947
- if (entry.source === "github") {
4948
- const pins = readRepoPins(this.pinFs, this.pinsPath());
4949
- writeRepoPins(this.pinFs, this.pinsPath(), {
4950
- ...pins,
4951
- [identityKey(entry)]: entry.version
4952
- });
4953
- }
4954
- this.installs.set(running.installId, running);
4955
- this.installOrder.push(running.installId);
4956
- this.evictFinishedInstalls();
5655
+ this.recordGithubPin(entry);
5656
+ this.track(running);
4957
5657
  return {
4958
5658
  ok: true,
4959
5659
  installId: running.installId,
4960
5660
  state: running.status().state
4961
5661
  };
4962
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
+ }
4963
5765
  /** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
4964
5766
  * oldest finished ones (insertion order, oldest first). Live records —
4965
5767
  * running AND queued — are never evicted; an id absent from the map reports
@@ -5100,7 +5902,8 @@ let ShopGateway = (() => {
5100
5902
  * dependencies the shop does not manage (the base bundle, the shop
5101
5903
  * itself). The same install records/polling serve the client. */
5102
5904
  async uninstall(args) {
5103
- if (isDesktopProfile(this.profile)) return {
5905
+ const manager = this.pluginManager();
5906
+ if (isDesktopProfile(this.profile) && manager === null) return {
5104
5907
  ok: false,
5105
5908
  detail: DESKTOP_PROFILE_DETAIL
5106
5909
  };
@@ -5117,6 +5920,23 @@ let ShopGateway = (() => {
5117
5920
  const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
5118
5921
  const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
5119
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
+ }
5120
5940
  const running = startUninstall({
5121
5941
  profile: this.profile,
5122
5942
  name: args.name,
@@ -5133,18 +5953,8 @@ let ShopGateway = (() => {
5133
5953
  }) };
5134
5954
  }
5135
5955
  });
5136
- const pins = readRepoPins(this.pinFs, this.pinsPath());
5137
- const stalePins = [args.name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
5138
- let forgot = false;
5139
- for (const key of stalePins) {
5140
- if (pins[key] === void 0) continue;
5141
- delete pins[key];
5142
- forgot = true;
5143
- }
5144
- if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
5145
- this.installs.set(running.installId, running);
5146
- this.installOrder.push(running.installId);
5147
- this.evictFinishedInstalls();
5956
+ this.forgetPins(args.name, installedEntry);
5957
+ this.track(running);
5148
5958
  return {
5149
5959
  ok: true,
5150
5960
  installId: running.installId
@@ -5156,6 +5966,10 @@ let ShopGateway = (() => {
5156
5966
  * response is out. The browser monitors the origin and refreshes when the
5157
5967
  * new server answers. Refusals are issued before anything is torn down. */
5158
5968
  async restart() {
5969
+ if (this.committedRestart !== null) return {
5970
+ ok: true,
5971
+ logFile: this.committedRestart.logFile
5972
+ };
5159
5973
  if (this.hasRunningCommand()) return {
5160
5974
  ok: false,
5161
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."
@@ -5189,6 +6003,7 @@ let ShopGateway = (() => {
5189
6003
  detail: `dsh-plugin-shop: restart could not be started: ${error.message}`
5190
6004
  };
5191
6005
  }
6006
+ this.committedRestart = { logFile };
5192
6007
  setTimeout(() => this.exit(0), this.restartExitDelayMs);
5193
6008
  return {
5194
6009
  ok: true,
@@ -5237,7 +6052,8 @@ let ShopGateway = (() => {
5237
6052
  * version is re-validated as plain semver at the boundary — the spec
5238
6053
  * `dsh-plugin-shop@<version>` is built here, never from the wire. */
5239
6054
  async updateStart(args) {
5240
- if (isDesktopProfile(this.profile)) return {
6055
+ const manager = this.pluginManager();
6056
+ if (isDesktopProfile(this.profile) && manager === null) return {
5241
6057
  ok: false,
5242
6058
  detail: DESKTOP_PROFILE_DETAIL
5243
6059
  };
@@ -5245,16 +6061,24 @@ let ShopGateway = (() => {
5245
6061
  ok: false,
5246
6062
  detail: `dsh-plugin-shop: ${args.version} is not a valid version`
5247
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
+ }
5248
6074
  const running = startInstall({
5249
6075
  profile: this.profile,
5250
- spec: `dsh-plugin-shop@${args.version}`,
6076
+ spec,
5251
6077
  dshBin: this.dshBin,
5252
6078
  expectedName: "dsh-plugin-shop",
5253
6079
  prefetcher: this.prefetcher
5254
6080
  });
5255
- this.installs.set(running.installId, running);
5256
- this.installOrder.push(running.installId);
5257
- this.evictFinishedInstalls();
6081
+ this.track(running);
5258
6082
  return {
5259
6083
  ok: true,
5260
6084
  installId: running.installId,