dsh-plugin-shop 0.4.3 → 0.4.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/client.js CHANGED
@@ -4350,10 +4350,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4350
4350
  ]).optional(),
4351
4351
  "detail": union([_undefined(), string()]).optional()
4352
4352
  });
4353
- const dsh_plugin_shop_shop_restart_result$schema = union([object({
4354
- "ok": literal(true),
4355
- "url": string()
4356
- }), object({
4353
+ const dsh_plugin_shop_shop_restart_result$schema = union([object({ "ok": literal(true) }), object({
4357
4354
  "ok": literal(false),
4358
4355
  "detail": string()
4359
4356
  })]);
@@ -4400,7 +4397,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4400
4397
  },
4401
4398
  sourceLocation: {
4402
4399
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4403
- "line": 218,
4400
+ "line": 225,
4404
4401
  "column": 9
4405
4402
  }
4406
4403
  },
@@ -4418,7 +4415,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4418
4415
  },
4419
4416
  sourceLocation: {
4420
4417
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4421
- "line": 293,
4418
+ "line": 300,
4422
4419
  "column": 9
4423
4420
  }
4424
4421
  },
@@ -4446,7 +4443,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4446
4443
  },
4447
4444
  sourceLocation: {
4448
4445
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4449
- "line": 244,
4446
+ "line": 251,
4450
4447
  "column": 9
4451
4448
  }
4452
4449
  },
@@ -4473,7 +4470,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4473
4470
  },
4474
4471
  sourceLocation: {
4475
4472
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4476
- "line": 282,
4473
+ "line": 289,
4477
4474
  "column": 3
4478
4475
  }
4479
4476
  },
@@ -4491,7 +4488,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4491
4488
  },
4492
4489
  sourceLocation: {
4493
4490
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4494
- "line": 367,
4491
+ "line": 373,
4495
4492
  "column": 9
4496
4493
  }
4497
4494
  },
@@ -4518,7 +4515,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4518
4515
  },
4519
4516
  sourceLocation: {
4520
4517
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4521
- "line": 187,
4518
+ "line": 194,
4522
4519
  "column": 3
4523
4520
  }
4524
4521
  },
@@ -4546,7 +4543,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4546
4543
  },
4547
4544
  sourceLocation: {
4548
4545
  "file": "packages/dsh-plugin-shop/src/host/index.ts",
4549
- "line": 333,
4546
+ "line": 340,
4550
4547
  "column": 9
4551
4548
  }
4552
4549
  }
@@ -4637,6 +4634,11 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4637
4634
  "shop",
4638
4635
  "marketplace"
4639
4636
  ];
4637
+ /** Competing marketplaces whose names carry no store/market keyword and so
4638
+ * escape the pattern rules below. Named explicitly rather than guessed:
4639
+ * `dsh-plugin` is the npm package of github.com/dshplugin/dsh-plugin-hub —
4640
+ * a community plugin marketplace for DeepSeek Harness. */
4641
+ const SHOP_LIKE_NAMES = ["dsh-plugin"];
4640
4642
  /**
4641
4643
  * Whether a package name reads as a plugin shop (a marketplace for dsh
4642
4644
  * plugins, e.g. `dsh-plugin-shop`, `dsh-store`, `pluginstore`). The shop
@@ -4649,6 +4651,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4649
4651
  * it matches other people's package names, not ours.
4650
4652
  */
4651
4653
  function isShopLike(name) {
4654
+ if (SHOP_LIKE_NAMES.includes(name.toLowerCase())) return true;
4652
4655
  const segments = name.toLowerCase().split(/[-_.]+/);
4653
4656
  const hasPlugin = segments.includes("plugin");
4654
4657
  if (segments.some((segment) => /plugin(store|market|mall|shop|marketplace)/.test(segment) || /(store|market|mall|shop|marketplace)plugin/.test(segment))) return true;
@@ -4763,7 +4766,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4763
4766
  restartTitle: "确认重启 dsh",
4764
4767
  restartBody: "重启会断开当前页面与服务器的连接,正在进行的对话和任务会中断;浏览器会自动跳转到重启后的地址,稍等片刻即可继续使用。",
4765
4768
  restartConfirm: "重启",
4766
- restarting: "正在重启,浏览器即将跳转…",
4769
+ restarting: "正在重启,服务器就绪后页面会自动刷新…",
4770
+ restartFailedNotice: "重启后服务器没有回来。请手动启动:dsh web(新进程的日志在商店缓存目录的 restart.log)",
4767
4771
  restartTransportFailed: "重启请求未能送达。请稍后重试。",
4768
4772
  enabledSwitch: "启用",
4769
4773
  toggleFailed: "设置失败,请重试。",
@@ -4828,7 +4832,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
4828
4832
  restartTitle: "Restart dsh?",
4829
4833
  restartBody: "Restarting disconnects this page and interrupts any conversation or task in progress; the browser will jump to the restarted address automatically, so give it a few seconds.",
4830
4834
  restartConfirm: "Restart",
4831
- restarting: "Restarting; the browser will jump in a moment…",
4835
+ restarting: "Restarting; the page will refresh once the server is back…",
4836
+ restartFailedNotice: "The server did not come back after the restart. Start it manually: dsh web (see restart.log in the shop cache directory)",
4832
4837
  restartTransportFailed: "The restart request could not be delivered. Please retry.",
4833
4838
  enabledSwitch: "Enable",
4834
4839
  toggleFailed: "Failed to update. Please retry.",
@@ -5376,22 +5381,27 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5376
5381
  /** The §8 restart flow (amendment 2026-08-27): after an install, update, or
5377
5382
  * uninstall reports done, this panel offers a restart of dsh. The
5378
5383
  * confirmation gate states the cost — the page disconnects and in-flight
5379
- * conversations/tasks are interrupted — and on confirm the restart RPC runs;
5380
- * the returned URL is where the browser jumps. A failed restart renders the
5381
- * host's published detail: the old server is still up, nothing was lost. */
5384
+ * conversations/tasks are interrupted — and on confirm the restart RPC
5385
+ * commits the two-phase handoff: the host exits, a helper re-runs dsh, and
5386
+ * this panel polls the origin after a grace period, refreshing the page
5387
+ * once the NEW server answers. A refused restart renders the host's
5388
+ * published detail; a server that never comes back names the manual
5389
+ * command. */
5382
5390
  function RestartPanel({ t, restart }) {
5383
5391
  const [gateOpen, setGateOpen] = (0, react.useState)(false);
5384
5392
  const [state, setState] = (0, react.useState)({ kind: "idle" });
5385
5393
  const onConfirm = async () => {
5386
5394
  setGateOpen(false);
5387
- setState({ kind: "restarting" });
5388
5395
  try {
5389
5396
  const result = await restart();
5390
- if (result.ok) window.location.href = result.url;
5391
- else setState({
5392
- kind: "failed",
5393
- detail: result.detail
5394
- });
5397
+ if (!result.ok) {
5398
+ setState({
5399
+ kind: "failed",
5400
+ detail: result.detail
5401
+ });
5402
+ return;
5403
+ }
5404
+ setState({ kind: "restarting" });
5395
5405
  } catch {
5396
5406
  setState({
5397
5407
  kind: "failed",
@@ -5399,6 +5409,25 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
5399
5409
  });
5400
5410
  }
5401
5411
  };
5412
+ (0, react.useEffect)(() => {
5413
+ if (state.kind !== "restarting") return;
5414
+ const started = Date.now();
5415
+ const timer = setInterval(() => {
5416
+ const elapsed = Date.now() - started;
5417
+ if (elapsed < 3e3) return;
5418
+ fetch(window.location.href, { cache: "no-store" }).then(() => {
5419
+ window.location.reload();
5420
+ }, () => {});
5421
+ if (elapsed > 3e4) {
5422
+ clearInterval(timer);
5423
+ setState({
5424
+ kind: "failed",
5425
+ detail: t("restartFailedNotice")
5426
+ });
5427
+ }
5428
+ }, INSTALL_POLL_MS);
5429
+ return () => clearInterval(timer);
5430
+ }, [state, t]);
5402
5431
  if (state.kind === "restarting") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5403
5432
  className: _dsh_plugin_shop_css_e3675a89_default.notice,
5404
5433
  "data-shop-restarting": true,
package/lib/index.js CHANGED
@@ -2,9 +2,9 @@ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
2
2
  import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
3
3
  import { lt, minVersion } from "semver";
4
4
  import { fileURLToPath } from "node:url";
5
- import { createHash, randomUUID } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
7
5
  import { basename, dirname, join } from "node:path";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
8
8
  import { z } from "zod";
9
9
  import { spawn } from "node:child_process";
10
10
  import { dump } from "js-yaml";
@@ -419,82 +419,50 @@ function startUninstall(options) {
419
419
  }
420
420
  //#endregion
421
421
  //#region src/host/restart.ts
422
- /** Restart executor: re-spawn the Host's own command line and hand the
423
- * browser the new server's URL. The child is detached (its own process
424
- * group), so it survives both the parent's exit and the launching
425
- * terminal's. The parent exits only AFTER the child printed its
426
- * `dsh web: <url>` line — a restart that fails to come up leaves the old
427
- * process running untouched. */
428
- /** How long to wait for the child to print its URL before declaring the
429
- * restart failed and killing the child. dsh web prints the URL once the
430
- * Loader tree settles; 20s is generous on slow machines. */
431
- const RESTART_UP_TIMEOUT_MS = 2e4;
432
- /** Spawn the restarted server and wait for it to announce its URL.
422
+ /** Restart executor: hand the port to a new dsh instance, two-phase.
433
423
  *
434
- * The child runs the same `dsh` with the same argv the current process was
435
- * launched with (`process.argv.slice(2)` node and the CLI script path
436
- * stripped), so the profile, port and flags reproduce the user's launch
437
- * verbatim. `--port 0` therefore yields a NEW port, and the returned URL is
438
- * how the browser finds it.
424
+ * The old process cannot wait for the new one: the new one must bind the
425
+ * port the old one still holds, and two live processes cannot bind it at
426
+ * once the first implementation spawned the child and waited for its URL,
427
+ * and the child crashed in boot with EADDRINUSE every time. The handoff is
428
+ * therefore inverted: the parent commits and exits FIRST, and a detached
429
+ * helper waits for the parent's pid to disappear before exec'ing the same
430
+ * dsh command line. The browser monitors the origin and refreshes once the
431
+ * new server answers; a boot that fails is diagnosed from the log file,
432
+ * since nobody is attached to the child's pipes. */
433
+ /** Spawn the two-phase handoff. The helper is a POSIX shell wrapper that
434
+ * polls the parent pid until it is gone, then replaces itself with the dsh
435
+ * command — `exec "$@"` keeps the argv verbatim, so no argument quoting is
436
+ * involved. The child's stdout/stderr go to `logFile`, opened here in
437
+ * append mode; opening throws on failure, and the caller treats a throw as
438
+ * a refusal (the restart is never committed without its log).
439
439
  *
440
- * On success the caller exits the old process but only after delivering
441
- * the RPC response carrying `url`, which is the caller's sequencing duty,
442
- * not this module's. On failure (child exits before announcing, spawn
443
- * error, or the timeout) the child is killed if still running and the old
444
- * process is untouched. */
440
+ * The pid-poll has the usual tiny reuse raceif the parent's pid is
441
+ * recycled within the 0.2s polling gap the helper waits for the unrelated
442
+ * process too. Harmless: it only delays the boot. */
445
443
  function startRestart(options) {
446
- const { dshBin, argv, env, timeoutMs = RESTART_UP_TIMEOUT_MS } = options;
447
- const stderr = [];
448
- return new Promise((resolve) => {
449
- const child = spawn(dshBin, argv, {
444
+ const { dshBin, argv, parentPid, logFile, env } = options;
445
+ const logFd = openSync(logFile, "a");
446
+ try {
447
+ spawn("sh", [
448
+ "-c",
449
+ "while kill -0 \"$1\" 2>/dev/null; do sleep 0.2; done; shift; exec \"$@\"",
450
+ "sh",
451
+ String(parentPid),
452
+ dshBin,
453
+ ...argv
454
+ ], {
450
455
  stdio: [
451
456
  "ignore",
452
- "pipe",
453
- "pipe"
457
+ logFd,
458
+ logFd
454
459
  ],
455
460
  env: env ?? process.env,
456
461
  detached: true
457
- });
458
- child.unref();
459
- const timeout = setTimeout(() => {
460
- child.kill();
461
- resolve({
462
- ok: false,
463
- detail: "the restarted server did not announce its URL in time"
464
- });
465
- }, timeoutMs);
466
- child.stdout.on("data", (chunk) => {
467
- for (const line of chunk.toString().split("\n")) {
468
- if (line === "") continue;
469
- const match = /dsh web: (http:\/\/\S+)/.exec(line);
470
- if (match?.[1] !== void 0) {
471
- clearTimeout(timeout);
472
- resolve({
473
- ok: true,
474
- url: match[1]
475
- });
476
- }
477
- }
478
- });
479
- child.stderr.on("data", (chunk) => {
480
- for (const line of chunk.toString().split("\n")) if (line !== "") stderr.push(line);
481
- });
482
- child.on("error", (error) => {
483
- const code = error.code;
484
- clearTimeout(timeout);
485
- resolve({
486
- ok: false,
487
- detail: code === "ENOENT" ? "dsh not found on PATH — restart could not be launched" : `restart spawn failed: ${error.message}`
488
- });
489
- });
490
- child.on("close", () => {
491
- clearTimeout(timeout);
492
- resolve({
493
- ok: false,
494
- detail: `the restarted server exited during boot — ${stderr[stderr.length - 1] ?? "no output"}`
495
- });
496
- });
497
- });
462
+ }).unref();
463
+ } finally {
464
+ closeSync(logFd);
465
+ }
498
466
  }
499
467
  //#endregion
500
468
  //#region src/host/profile.ts
@@ -731,6 +699,7 @@ let ShopGateway = (() => {
731
699
  * production, a spy in tests. */
732
700
  exit;
733
701
  restartExitDelayMs;
702
+ restartParentPid;
734
703
  /** The install gate runs against the last loaded snapshot, never a fresh
735
704
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
736
705
  /** Finished install records retained, so a poll sees the true terminal
@@ -756,6 +725,7 @@ let ShopGateway = (() => {
756
725
  this.restartArgv = options.restartArgv ?? process.argv.slice(2);
757
726
  this.exit = options.exit ?? ((code) => process.exit(code));
758
727
  this.restartExitDelayMs = options.restartExitDelayMs ?? ShopGateway.RESTART_EXIT_DELAY_MS;
728
+ this.restartParentPid = options.restartParentPid ?? process.pid;
759
729
  }
760
730
  /** The boot's Loader root directory (the active profile's `cordis.yml`
761
731
  * directory, carried on `ctx.baseUrl`), when present. A `link:` install
@@ -971,19 +941,33 @@ let ShopGateway = (() => {
971
941
  };
972
942
  }
973
943
  /** Restart the dsh process the shop runs in (§8 amendment, 2026-08-27):
974
- * re-spawn this process's own command line, return the new server's URL
975
- * once it announces itself, and only then exit. A failed restart returns a
976
- * typed failure and the old process keeps serving the restart is
977
- * all-or-nothing. The response must reach the browser before the exit, so
978
- * the exit is delayed past the RPC round-trip. */
944
+ * commit a two-phase handoff a detached helper waits for this pid to
945
+ * exit, then re-runs this process's own command line and exit once the
946
+ * response is out. The browser monitors the origin and refreshes when the
947
+ * new server answers. Refusals are issued before anything is torn down. */
979
948
  async restart() {
980
- const outcome = await startRestart({
981
- dshBin: this.dshBin,
982
- argv: this.restartArgv,
983
- env: process.env
984
- });
985
- if (outcome.ok) setTimeout(() => this.exit(0), this.restartExitDelayMs);
986
- return outcome;
949
+ const portIndex = this.restartArgv.indexOf("--port");
950
+ if (portIndex !== -1 && this.restartArgv[portIndex + 1] === "0") return {
951
+ ok: false,
952
+ detail: "dsh-plugin-shop: restart is not supported when dsh was launched with --port 0; restart dsh manually"
953
+ };
954
+ try {
955
+ const { cacheDir } = this.rowConfig();
956
+ startRestart({
957
+ dshBin: this.dshBin,
958
+ argv: this.restartArgv,
959
+ parentPid: this.restartParentPid,
960
+ logFile: join(cacheDir, "restart.log"),
961
+ env: process.env
962
+ });
963
+ } catch (error) {
964
+ return {
965
+ ok: false,
966
+ detail: `dsh-plugin-shop: restart could not be started: ${error.message}`
967
+ };
968
+ }
969
+ setTimeout(() => this.exit(0), this.restartExitDelayMs);
970
+ return { ok: true };
987
971
  }
988
972
  };
989
973
  })();
@@ -69,7 +69,6 @@ const dsh_plugin_shop_shop_installStatus_result$schema = z.object({
69
69
  })
70
70
  const dsh_plugin_shop_shop_restart_result$schema = z.union([z.object({
71
71
  'ok': z.literal(true),
72
- 'url': z.string(),
73
72
  }), z.object({
74
73
  'ok': z.literal(false),
75
74
  'detail': z.string(),
@@ -123,7 +122,7 @@ export const TYPERT = {
123
122
  typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
124
123
  schema: dsh_plugin_shop_shop_catalog_result$schema,
125
124
  },
126
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":218,"column":9},
125
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":225,"column":9},
127
126
  },
128
127
  {
129
128
  id: 'dsh-plugin-shop#shop/installed',
@@ -138,7 +137,7 @@ export const TYPERT = {
138
137
  typeSymbol: 'dsh-plugin-shop#shop/installed:result',
139
138
  schema: dsh_plugin_shop_shop_installed_result$schema,
140
139
  },
141
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":293,"column":9},
140
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":300,"column":9},
142
141
  },
143
142
  {
144
143
  id: 'dsh-plugin-shop#shop/installStart',
@@ -164,7 +163,7 @@ export const TYPERT = {
164
163
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
165
164
  schema: dsh_plugin_shop_shop_installStart_result$schema,
166
165
  },
167
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":244,"column":9},
166
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":251,"column":9},
168
167
  },
169
168
  {
170
169
  id: 'dsh-plugin-shop#shop/installStatus',
@@ -189,7 +188,7 @@ export const TYPERT = {
189
188
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
190
189
  schema: dsh_plugin_shop_shop_installStatus_result$schema,
191
190
  },
192
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":282,"column":3},
191
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":289,"column":3},
193
192
  },
194
193
  {
195
194
  id: 'dsh-plugin-shop#shop/restart',
@@ -204,7 +203,7 @@ export const TYPERT = {
204
203
  typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
205
204
  schema: dsh_plugin_shop_shop_restart_result$schema,
206
205
  },
207
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":367,"column":9},
206
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":373,"column":9},
208
207
  },
209
208
  {
210
209
  id: 'dsh-plugin-shop#shop/setEnabled',
@@ -229,7 +228,7 @@ export const TYPERT = {
229
228
  typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
230
229
  schema: dsh_plugin_shop_shop_setEnabled_result$schema,
231
230
  },
232
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":187,"column":3},
231
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":194,"column":3},
233
232
  },
234
233
  {
235
234
  id: 'dsh-plugin-shop#shop/uninstallStart',
@@ -255,7 +254,7 @@ export const TYPERT = {
255
254
  typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
256
255
  schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
257
256
  },
258
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":333,"column":9},
257
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":340,"column":9},
259
258
  },
260
259
  ],
261
260
  model: {
@@ -320,8 +319,8 @@ export const TYPERT = {
320
319
  "kind": "method",
321
320
  "name": "restart",
322
321
  "signature": "@Remote('restart') async restart(): Promise<ShopRestartResult>",
323
- "summary": "Restart the dsh process the shop runs in (§8 amendment, 2026-08-27): re-spawn this process's own command line, return the new server's URL once it announces itself, and only then exit.",
324
- "jsDoc": "/** Restart the dsh process the shop runs in (§8 amendment, 2026-08-27):\n * re-spawn this process's own command line, return the new server's URL\n * once it announces itself, and only then exit. A failed restart returns a\n * typed failure and the old process keeps serving the restart is\n * all-or-nothing. The response must reach the browser before the exit, so\n * the exit is delayed past the RPC round-trip. */"
322
+ "summary": "Restart the dsh process the shop runs in (§8 amendment, 2026-08-27): commit a two-phase handoff — a detached helper waits for this pid to exit, then re-runs this process's own command line and exit once the response is out.",
323
+ "jsDoc": "/** Restart the dsh process the shop runs in (§8 amendment, 2026-08-27):\n * commit a two-phase handoff a detached helper waits for this pid to\n * exit, then re-runs this process's own command line and exit once the\n * response is out. The browser monitors the origin and refreshes when the\n * new server answers. Refusals are issued before anything is torn down. */"
325
324
  }
326
325
  ],
327
326
  "types": [
@@ -359,7 +358,7 @@ export const TYPERT = {
359
358
  },
360
359
  {
361
360
  "name": "RestartOutcome",
362
- "declaration": "export type RestartOutcome = { ok: true; url: string; } | { ok: false; detail: string; };"
361
+ "declaration": "export type RestartOutcome = { ok: true; } | { ok: false; detail: string; };"
363
362
  },
364
363
  {
365
364
  "name": "ShopCatalogResult",
@@ -69,7 +69,6 @@ const dsh_plugin_shop_shop_installStatus_result$schema = z.object({
69
69
  })
70
70
  const dsh_plugin_shop_shop_restart_result$schema = z.union([z.object({
71
71
  'ok': z.literal(true),
72
- 'url': z.string(),
73
72
  }), z.object({
74
73
  'ok': z.literal(false),
75
74
  'detail': z.string(),
@@ -120,7 +119,7 @@ export const TYPERT_REMOTE = {
120
119
  typeSymbol: 'dsh-plugin-shop/types#ShopCatalogResult',
121
120
  schema: dsh_plugin_shop_shop_catalog_result$schema,
122
121
  },
123
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":218,"column":9},
122
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":225,"column":9},
124
123
  },
125
124
  {
126
125
  id: 'dsh-plugin-shop#shop/installed',
@@ -135,7 +134,7 @@ export const TYPERT_REMOTE = {
135
134
  typeSymbol: 'dsh-plugin-shop#shop/installed:result',
136
135
  schema: dsh_plugin_shop_shop_installed_result$schema,
137
136
  },
138
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":293,"column":9},
137
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":300,"column":9},
139
138
  },
140
139
  {
141
140
  id: 'dsh-plugin-shop#shop/installStart',
@@ -161,7 +160,7 @@ export const TYPERT_REMOTE = {
161
160
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallResult',
162
161
  schema: dsh_plugin_shop_shop_installStart_result$schema,
163
162
  },
164
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":244,"column":9},
163
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":251,"column":9},
165
164
  },
166
165
  {
167
166
  id: 'dsh-plugin-shop#shop/installStatus',
@@ -186,7 +185,7 @@ export const TYPERT_REMOTE = {
186
185
  typeSymbol: 'dsh-plugin-shop/types#ShopInstallStatusResult',
187
186
  schema: dsh_plugin_shop_shop_installStatus_result$schema,
188
187
  },
189
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":282,"column":3},
188
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":289,"column":3},
190
189
  },
191
190
  {
192
191
  id: 'dsh-plugin-shop#shop/restart',
@@ -201,7 +200,7 @@ export const TYPERT_REMOTE = {
201
200
  typeSymbol: 'dsh-plugin-shop/types#ShopRestartResult',
202
201
  schema: dsh_plugin_shop_shop_restart_result$schema,
203
202
  },
204
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":367,"column":9},
203
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":373,"column":9},
205
204
  },
206
205
  {
207
206
  id: 'dsh-plugin-shop#shop/setEnabled',
@@ -226,7 +225,7 @@ export const TYPERT_REMOTE = {
226
225
  typeSymbol: 'dsh-plugin-shop/types#ShopSetEnabledResult',
227
226
  schema: dsh_plugin_shop_shop_setEnabled_result$schema,
228
227
  },
229
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":187,"column":3},
228
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":194,"column":3},
230
229
  },
231
230
  {
232
231
  id: 'dsh-plugin-shop#shop/uninstallStart',
@@ -252,7 +251,7 @@ export const TYPERT_REMOTE = {
252
251
  typeSymbol: 'dsh-plugin-shop/types#ShopUninstallResult',
253
252
  schema: dsh_plugin_shop_shop_uninstallStart_result$schema,
254
253
  },
255
- sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":333,"column":9},
254
+ sourceLocation: {"file":"packages/dsh-plugin-shop/src/host/index.ts","line":340,"column":9},
256
255
  },
257
256
  ],
258
257
  }
@@ -59,6 +59,7 @@ export declare const zh: {
59
59
  restartBody: string;
60
60
  restartConfirm: string;
61
61
  restarting: string;
62
+ restartFailedNotice: string;
62
63
  restartTransportFailed: string;
63
64
  enabledSwitch: string;
64
65
  toggleFailed: string;
@@ -126,6 +127,7 @@ export declare const en: {
126
127
  restartBody: string;
127
128
  restartConfirm: string;
128
129
  restarting: string;
130
+ restartFailedNotice: string;
129
131
  restartTransportFailed: string;
130
132
  enabledSwitch: string;
131
133
  toggleFailed: string;
@@ -58,6 +58,13 @@ export type InstallEvent = {
58
58
  };
59
59
  /** §7.2 once-per-second poll cadence, as a named constant. */
60
60
  export declare const INSTALL_POLL_MS = 1000;
61
+ /** §8 restart handoff: the client polls the origin only after this grace
62
+ * period (the host's exit delay plus margin), so an origin that answers is
63
+ * the NEW server, never the dying old one. */
64
+ export declare const RESTART_GRACE_MS = 3000;
65
+ /** §8 restart handoff: how long the client waits for the new server to
66
+ * answer before reporting the manual restart command. */
67
+ export declare const RESTART_WAIT_MS = 30000;
61
68
  /** How many shelf cards mount at a time. The shelf holds ~1900 entries; one
62
69
  * commit of the whole grid is ~28k DOM nodes, so the list renders in batches
63
70
  * behind a sentinel (§A1) and grows on scroll. 48 is ~3-4 rows. */
@@ -36,6 +36,10 @@ export interface ShopGatewayOptions {
36
36
  /** Test-only injection: the exit the restart calls after the response is
37
37
  * delivered. Production uses `process.exit`. */
38
38
  exit?: (code?: number) => void;
39
+ /** The pid the restart helper waits on before exec'ing the new dsh;
40
+ * defaults to this process. Tests point it at a dead pid so the fixture
41
+ * runs immediately instead of waiting for the vitest worker to exit. */
42
+ restartParentPid?: number;
39
43
  /** How long the gateway waits after a successful restart response before
40
44
  * exiting the old process; test-only shortening, production uses 2s. */
41
45
  restartExitDelayMs?: number;
@@ -111,6 +115,7 @@ export declare class ShopGateway extends TypertRemoteService {
111
115
  * production, a spy in tests. */
112
116
  private readonly exit;
113
117
  private readonly restartExitDelayMs;
118
+ private readonly restartParentPid;
114
119
  /** The install gate runs against the last loaded snapshot, never a fresh
115
120
  * fetch per request (§7.2: the Host's cached snapshot is the truth). */
116
121
  /** Finished install records retained, so a poll sees the true terminal
@@ -184,11 +189,10 @@ export declare class ShopGateway extends TypertRemoteService {
184
189
  name: string;
185
190
  }): Promise<ShopUninstallResult>;
186
191
  /** Restart the dsh process the shop runs in (§8 amendment, 2026-08-27):
187
- * re-spawn this process's own command line, return the new server's URL
188
- * once it announces itself, and only then exit. A failed restart returns a
189
- * typed failure and the old process keeps serving the restart is
190
- * all-or-nothing. The response must reach the browser before the exit, so
191
- * the exit is delayed past the RPC round-trip. */
192
+ * commit a two-phase handoff a detached helper waits for this pid to
193
+ * exit, then re-runs this process's own command line and exit once the
194
+ * response is out. The browser monitors the origin and refreshes when the
195
+ * new server answers. Refusals are issued before anything is torn down. */
192
196
  restart(): Promise<ShopRestartResult>;
193
197
  }
194
198
  export default ShopGateway;
@@ -1,35 +1,38 @@
1
- /** Restart executor: re-spawn the Host's own command line and hand the
2
- * browser the new server's URL. The child is detached (its own process
3
- * group), so it survives both the parent's exit and the launching
4
- * terminal's. The parent exits only AFTER the child printed its
5
- * `dsh web: <url>` line a restart that fails to come up leaves the old
6
- * process running untouched. */
7
- /** `shop/restart` outcome: the new server's URL, or a typed failure with an
8
- * author-readable detail. A failure means the OLD process is still serving
9
- * the restart is all-or-nothing. */
1
+ /** Restart executor: hand the port to a new dsh instance, two-phase.
2
+ *
3
+ * The old process cannot wait for the new one: the new one must bind the
4
+ * port the old one still holds, and two live processes cannot bind it at
5
+ * once the first implementation spawned the child and waited for its URL,
6
+ * and the child crashed in boot with EADDRINUSE every time. The handoff is
7
+ * therefore inverted: the parent commits and exits FIRST, and a detached
8
+ * helper waits for the parent's pid to disappear before exec'ing the same
9
+ * dsh command line. The browser monitors the origin and refreshes once the
10
+ * new server answers; a boot that fails is diagnosed from the log file,
11
+ * since nobody is attached to the child's pipes. */
12
+ /** `shop/restart` result: committed, or a typed refusal issued BEFORE
13
+ * anything is torn down. Once `ok` is returned the old process WILL exit —
14
+ * the client monitors the new server and reports a failed boot with the
15
+ * manual command. */
10
16
  export type RestartOutcome = {
11
17
  ok: true;
12
- url: string;
13
18
  } | {
14
19
  ok: false;
15
20
  detail: string;
16
21
  };
17
- /** Spawn the restarted server and wait for it to announce its URL.
18
- *
19
- * The child runs the same `dsh` with the same argv the current process was
20
- * launched with (`process.argv.slice(2)` node and the CLI script path
21
- * stripped), so the profile, port and flags reproduce the user's launch
22
- * verbatim. `--port 0` therefore yields a NEW port, and the returned URL is
23
- * how the browser finds it.
22
+ /** Spawn the two-phase handoff. The helper is a POSIX shell wrapper that
23
+ * polls the parent pid until it is gone, then replaces itself with the dsh
24
+ * command `exec "$@"` keeps the argv verbatim, so no argument quoting is
25
+ * involved. The child's stdout/stderr go to `logFile`, opened here in
26
+ * append mode; opening throws on failure, and the caller treats a throw as
27
+ * a refusal (the restart is never committed without its log).
24
28
  *
25
- * On success the caller exits the old process but only after delivering
26
- * the RPC response carrying `url`, which is the caller's sequencing duty,
27
- * not this module's. On failure (child exits before announcing, spawn
28
- * error, or the timeout) the child is killed if still running and the old
29
- * process is untouched. */
29
+ * The pid-poll has the usual tiny reuse raceif the parent's pid is
30
+ * recycled within the 0.2s polling gap the helper waits for the unrelated
31
+ * process too. Harmless: it only delays the boot. */
30
32
  export declare function startRestart(options: {
31
33
  dshBin: string;
32
34
  argv: string[];
35
+ parentPid: number;
36
+ logFile: string;
33
37
  env?: NodeJS.ProcessEnv;
34
- timeoutMs?: number;
35
- }): Promise<RestartOutcome>;
38
+ }): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-shop",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "The DeepSeek Harness plugin shop: browse, install, enable, and update dsh plugins from a git-auditable catalog.",
5
5
  "repository": {
6
6
  "type": "git",