@deepseek-ai/dsh-plugin-manager 0.1.7-rc.1 → 0.1.7-rc.2

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/README.i18n.yaml CHANGED
@@ -1,6 +1,55 @@
1
- # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
- # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
- # after editing either side, bring the other along and re-record with:
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
5
- README.md: c047c71476308f0123bdebed6d9ab21e8ececc2f
6
- README.zh.md: 1f99f67498790d997a7e345ca301b85c319c7b09
5
+ /:
6
+ en: 65283627208ef4c9
7
+ zh: aa3f833ec24bb65a
8
+ /deepseek-ai-dsh-plugin-manager:
9
+ en: e6cbfe169f5fb57b
10
+ zh: be378036d645a242
11
+ /deepseek-ai-dsh-plugin-manager/summary:
12
+ en: d6afcaa1594efcdb
13
+ zh: 459cb6ac0dcb2d46
14
+ /deepseek-ai-dsh-plugin-manager/table-of-contents:
15
+ en: 87679c51aa3693f5
16
+ zh: 11684a48a5806afb
17
+ /deepseek-ai-dsh-plugin-manager/use-this-package:
18
+ en: b8d7061ee289b2a0
19
+ zh: 49aba7df4761fb23
20
+ /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
21
+ en: 920c0acb3b9feaf3
22
+ zh: ba6e832a256880a7
23
+ /deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
24
+ en: d5643a06f300c30a
25
+ zh: 9348b6a7676a2849
26
+ /deepseek-ai-dsh-plugin-manager/understand-the-implementation:
27
+ en: 1c737c375aeac163
28
+ zh: 009d7f139a749c79
29
+ /deepseek-ai-dsh-plugin-manager/further-exploration:
30
+ en: c6538243d8702a0d
31
+ zh: 04a82d923353c75f
32
+ /deepseek-ai-dsh-plugin-manager/model-experience:
33
+ en: 215e7ba838619b7b
34
+ zh: a311da3843709f90
35
+ /deepseek-ai-dsh-plugin-manager/model-experience/management-tool:
36
+ en: 1815ec6ce56c80d4
37
+ zh: 2d7ac75d3b25eb6b
38
+ /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/what-the-model-sees:
39
+ en: 72d6c70869b12f22
40
+ zh: 45218d6f1e765fb1
41
+ /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/token-effect:
42
+ en: bc70208bd361dbcd
43
+ zh: bb651d6066066c6c
44
+ /deepseek-ai-dsh-plugin-manager/model-experience/management-tool/kv-cache-effect:
45
+ en: c29f60be7f3edc1a
46
+ zh: d9b9388b96ef95cc
47
+ /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
48
+ en: fde2d9491fe5dd4f
49
+ zh: ef6e6db15c4f19d2
50
+ /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
51
+ en: 61450cf6e03c2819
52
+ zh: 4d52f5306825787b
53
+ /deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note:
54
+ en: e67f633dc6fe7773
55
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -89,7 +89,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
89
89
  <details>
90
90
  <summary>Implementation internals — click to expand</summary>
91
91
 
92
- The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads.
92
+ The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
93
93
 
94
94
  Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
95
95
 
package/README.zh.md CHANGED
@@ -89,7 +89,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
89
89
  <details>
90
90
  <summary>实现细节——点击展开</summary>
91
91
 
92
- 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。
92
+ 服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
93
93
 
94
94
  结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
95
95
 
package/lib/index.js CHANGED
@@ -140,29 +140,35 @@ function targetOf(pid, grouped, platform) {
140
140
  return platform !== "win32" && grouped ? -pid : pid;
141
141
  }
142
142
  /**
143
- * Wait until a terminated run's tree is gone, so the caller restores and
144
- * unlocks the profile only after the scripts it started stopped writing.
143
+ * Whether any member of a run's tree is still alive.
145
144
  * @param tree The run's process id and whether it leads its own group.
146
145
  * @param internals Injectable process operations.
147
- * @returns Fulfillment once no member remains, or the wait bound elapsed.
146
+ * @returns True while the probe finds the process or a group member.
148
147
  */
149
- async function awaitTreeGone(tree, internals = {}) {
148
+ function treeAlive(tree, internals = {}) {
150
149
  const pid = tree.pid;
151
- if (pid === void 0) return;
150
+ if (pid === void 0) return false;
152
151
  const platform = internals.platform ?? process.platform;
153
152
  const alive = internals.alive ?? ((target) => {
154
153
  process.kill(target, 0);
155
154
  return true;
156
155
  });
157
- const remaining = () => {
158
- try {
159
- return alive(targetOf(pid, tree.grouped, platform));
160
- } catch {
161
- return false;
162
- }
163
- };
156
+ try {
157
+ return alive(targetOf(pid, tree.grouped, platform));
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+ /**
163
+ * Wait until a terminated run's tree is gone, so the caller restores and
164
+ * unlocks the profile only after the scripts it started stopped writing.
165
+ * @param tree The run's process id and whether it leads its own group.
166
+ * @param internals Injectable process operations.
167
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
168
+ */
169
+ async function awaitTreeGone(tree, internals = {}) {
164
170
  const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
165
- while (remaining()) {
171
+ while (treeAlive(tree, internals)) {
166
172
  if (Date.now() >= deadline) return;
167
173
  await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS));
168
174
  }
@@ -347,6 +353,44 @@ function optionalFile(path) {
347
353
  throw error;
348
354
  }
349
355
  }
356
+ /** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
357
+ function runRecordPath(dir) {
358
+ return join(dir, ".plugin-manager", "run.json");
359
+ }
360
+ /** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
361
+ async function recordRun(dir, tree) {
362
+ if (tree.pid === void 0) return;
363
+ await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, {
364
+ mode: 384,
365
+ dirMode: 448
366
+ });
367
+ }
368
+ /**
369
+ * Why this operation must not run: a record left by an operation whose process
370
+ * ended mid-run names a run that is still writing the profile. The profile
371
+ * lock is taken over once its holder exits, but its pnpm tree can outlive it.
372
+ * A recorded run that stopped, within a bounded wait, has its record removed.
373
+ * @param dir Profile directory.
374
+ * @returns The diagnostic, or undefined when no recorded run is active.
375
+ */
376
+ async function activeRecordedRun(dir) {
377
+ const path = runRecordPath(dir);
378
+ const text = optionalFile(path);
379
+ if (text === void 0) return void 0;
380
+ let tree;
381
+ try {
382
+ const value = JSON.parse(text);
383
+ const { pid, grouped } = typeof value === "object" && value !== null ? value : {};
384
+ if (Number.isSafeInteger(pid) && pid > 0 && typeof grouped === "boolean") tree = {
385
+ pid,
386
+ grouped
387
+ };
388
+ } catch (error) {}
389
+ if (tree === void 0) return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`;
390
+ await awaitTreeGone(tree);
391
+ if (treeAlive(tree)) return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`;
392
+ await rm(path, { force: true });
393
+ }
350
394
  /** pnpm can install plugins through any direct-dependency field. */
351
395
  function directDependencies(manifest) {
352
396
  const extra = manifest;
@@ -393,13 +437,7 @@ function bundleComponentManifests(manifest, dir, anchor) {
393
437
  */
394
438
  async function runProfilePnpm(context, args, options) {
395
439
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
396
- const before = readProfileManifest("dsh", dir);
397
- const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
398
- path: join(dir, name),
399
- text: optionalFile(join(dir, name))
400
- }));
401
- const beforeDependencies = directDependencies(before);
402
- const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
440
+ const active = await activeRecordedRun(dir);
403
441
  const logRoot = join(dir, ".plugin-manager", "logs");
404
442
  await mkdir(logRoot, {
405
443
  recursive: true,
@@ -407,6 +445,25 @@ async function runProfilePnpm(context, args, options) {
407
445
  });
408
446
  const logPath = join(await mkdtemp(join(logRoot, "operation-")), "pnpm.log");
409
447
  const log = await open(logPath, "wx", 384);
448
+ if (active !== void 0) {
449
+ await log.write(active);
450
+ await log.close();
451
+ options.onOutput?.(active, "stderr");
452
+ const bytes = Buffer.from(active);
453
+ return {
454
+ exitCode: 1,
455
+ output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString("utf8"),
456
+ truncated: bytes.length > options.outputBytes,
457
+ logPath
458
+ };
459
+ }
460
+ const before = readProfileManifest("dsh", dir);
461
+ const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
462
+ path: join(dir, name),
463
+ text: optionalFile(join(dir, name))
464
+ }));
465
+ const beforeDependencies = directDependencies(before);
466
+ const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
410
467
  let output = Buffer.alloc(0);
411
468
  let truncated = false;
412
469
  const append = (bytes) => {
@@ -469,6 +526,7 @@ async function runProfilePnpm(context, args, options) {
469
526
  stdin: options.execution === "cli" ? "inherit" : "ignore",
470
527
  cancelSignal: options.signal === void 0 ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal])
471
528
  });
529
+ const exited = once(child.nodeChildProcess, "exit").catch(() => void 0);
472
530
  let writes = Promise.resolve();
473
531
  /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
474
532
  const control = {
@@ -514,8 +572,12 @@ async function runProfilePnpm(context, args, options) {
514
572
  if (collectors.length > 0) armIdle();
515
573
  let exitCode;
516
574
  try {
575
+ await recordRun(dir, {
576
+ pid: child.pid,
577
+ grouped
578
+ });
517
579
  const settled = Promise.allSettled([child]);
518
- await Promise.race([once(child.nodeChildProcess, "exit").catch(() => void 0), settled]);
580
+ await Promise.race([exited, settled]);
519
581
  control.settled = true;
520
582
  clearTimeout(idleTimer);
521
583
  if (control.stalled) await awaitTreeGone({
@@ -585,7 +647,7 @@ async function runProfilePnpm(context, args, options) {
585
647
  if (warnings.length > 0) {
586
648
  await restore();
587
649
  const repair = ["install", savedFiles.some((file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== void 0) ? "--frozen-lockfile" : "--config.lockfile=false"];
588
- const repaired = await execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
650
+ const repairing = execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
589
651
  cwd: dir,
590
652
  env: environment,
591
653
  extendEnv: false,
@@ -593,6 +655,11 @@ async function runProfilePnpm(context, args, options) {
593
655
  stdin: "ignore",
594
656
  ...options.idleTimeoutMs === void 0 ? {} : { timeout: options.idleTimeoutMs }
595
657
  });
658
+ await recordRun(dir, {
659
+ pid: repairing.pid,
660
+ grouped: false
661
+ });
662
+ const repaired = await repairing;
596
663
  exitCode = 1;
597
664
  const restoration = repaired.exitCode === 0 ? "restored package.json, pnpm-lock.yaml, and node_modules" : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
598
665
  const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
@@ -604,6 +671,7 @@ async function runProfilePnpm(context, args, options) {
604
671
  } finally {
605
672
  control.settled = true;
606
673
  clearTimeout(idleTimer);
674
+ await rm(runRecordPath(dir), { force: true });
607
675
  await log.close();
608
676
  }
609
677
  return {
@@ -8,7 +8,7 @@ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
8
8
  import { DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions, evaluatePluginCompatibility, pluginCompatibilityWarning, } from '@deepseek-ai/dsh-app-boot';
9
9
  import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess';
10
10
  import { parseInstallSpec } from "./install-spec.js";
11
- import { awaitTreeGone, leadsOwnGroup } from "./run-tree.js";
11
+ import { awaitTreeGone, leadsOwnGroup, treeAlive } from "./run-tree.js";
12
12
  import { incompatiblePlugin } from "./failure.js";
13
13
  export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot';
14
14
  /** Resolve relative package specs against the caller's directory.
@@ -152,6 +152,51 @@ function optionalFile(path) {
152
152
  throw error;
153
153
  }
154
154
  }
155
+ /** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
156
+ function runRecordPath(dir) {
157
+ return join(dir, '.plugin-manager', 'run.json');
158
+ }
159
+ /** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
160
+ async function recordRun(dir, tree) {
161
+ if (tree.pid === undefined)
162
+ return;
163
+ await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { mode: 0o600, dirMode: 0o700 });
164
+ }
165
+ /**
166
+ * Why this operation must not run: a record left by an operation whose process
167
+ * ended mid-run names a run that is still writing the profile. The profile
168
+ * lock is taken over once its holder exits, but its pnpm tree can outlive it.
169
+ * A recorded run that stopped, within a bounded wait, has its record removed.
170
+ * @param dir Profile directory.
171
+ * @returns The diagnostic, or undefined when no recorded run is active.
172
+ */
173
+ async function activeRecordedRun(dir) {
174
+ const path = runRecordPath(dir);
175
+ const text = optionalFile(path);
176
+ if (text === undefined)
177
+ return undefined;
178
+ let tree;
179
+ try {
180
+ const value = JSON.parse(text);
181
+ const { pid, grouped } = (typeof value === 'object' && value !== null ? value : {});
182
+ if (Number.isSafeInteger(pid) && pid > 0 && typeof grouped === 'boolean')
183
+ tree = { pid: pid, grouped };
184
+ }
185
+ catch (error) {
186
+ // Records are replaced atomically, so an unparsable one was written by something else and is reported below.
187
+ void error;
188
+ }
189
+ if (tree === undefined) {
190
+ return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`;
191
+ }
192
+ await awaitTreeGone(tree);
193
+ if (treeAlive(tree)) {
194
+ return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; `
195
+ + `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`;
196
+ }
197
+ await rm(path, { force: true });
198
+ return undefined;
199
+ }
155
200
  /** pnpm can install plugins through any direct-dependency field. */
156
201
  function directDependencies(manifest) {
157
202
  const extra = manifest;
@@ -200,15 +245,24 @@ function bundleComponentManifests(manifest, dir, anchor) {
200
245
  */
201
246
  export async function runProfilePnpm(context, args, options) {
202
247
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
203
- const before = readProfileManifest('dsh', dir);
204
- const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }));
205
- const beforeDependencies = directDependencies(before);
206
- const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
248
+ // Before any profile file is read: a recorded run that is still active may be rewriting them.
249
+ const active = await activeRecordedRun(dir);
207
250
  const logRoot = join(dir, '.plugin-manager', 'logs');
208
251
  await mkdir(logRoot, { recursive: true, mode: 0o700 });
209
252
  const logDir = await mkdtemp(join(logRoot, 'operation-'));
210
253
  const logPath = join(logDir, 'pnpm.log');
211
254
  const log = await open(logPath, 'wx', 0o600);
255
+ if (active !== undefined) {
256
+ await log.write(active);
257
+ await log.close();
258
+ options.onOutput?.(active, 'stderr');
259
+ const bytes = Buffer.from(active);
260
+ return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath };
261
+ }
262
+ const before = readProfileManifest('dsh', dir);
263
+ const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }));
264
+ const beforeDependencies = directDependencies(before);
265
+ const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
212
266
  let output = Buffer.alloc(0);
213
267
  let truncated = false;
214
268
  const append = (bytes) => {
@@ -276,6 +330,8 @@ export async function runProfilePnpm(context, args, options) {
276
330
  buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
277
331
  ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
278
332
  });
333
+ // Listened for before the record is written, so an exit during that write is not missed.
334
+ const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined);
279
335
  let writes = Promise.resolve();
280
336
  /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
281
337
  const control = { settled: false, stalled: false };
@@ -330,11 +386,12 @@ export async function runProfilePnpm(context, args, options) {
330
386
  armIdle();
331
387
  let exitCode;
332
388
  try {
389
+ await recordRun(dir, { pid: child.pid, grouped });
333
390
  // execa resolves its promise only once the piped stdio has ended, so the
334
391
  // process's own exit — the run's completion — is read from the raw child. A
335
392
  // spawn failure settles without one.
336
393
  const settled = Promise.allSettled([child]);
337
- await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled]);
394
+ await Promise.race([exited, settled]);
338
395
  control.settled = true;
339
396
  clearTimeout(idleTimer);
340
397
  // A stalled run stops its whole tree first, so the caller's rollback and lock
@@ -428,10 +485,12 @@ export async function runProfilePnpm(context, args, options) {
428
485
  await restore();
429
486
  const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
430
487
  const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
431
- const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
488
+ const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
432
489
  cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
433
490
  ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
434
491
  });
492
+ await recordRun(dir, { pid: repairing.pid, grouped: false });
493
+ const repaired = await repairing;
435
494
  exitCode = 1;
436
495
  const restoration = repaired.exitCode === 0
437
496
  ? 'restored package.json, pnpm-lock.yaml, and node_modules'
@@ -449,6 +508,8 @@ export async function runProfilePnpm(context, args, options) {
449
508
  finally {
450
509
  control.settled = true;
451
510
  clearTimeout(idleTimer);
511
+ // This process saw the run end, so no successor has to wait for it.
512
+ await rm(runRecordPath(dir), { force: true });
452
513
  await log.close();
453
514
  }
454
515
  return {
@@ -26,6 +26,13 @@ export interface RunTreeInternals {
26
26
  * @returns True when the run leads its own process group.
27
27
  */
28
28
  export declare function leadsOwnGroup(execution: 'cli' | 'service', platform?: NodeJS.Platform): boolean;
29
+ /**
30
+ * Whether any member of a run's tree is still alive.
31
+ * @param tree The run's process id and whether it leads its own group.
32
+ * @param internals Injectable process operations.
33
+ * @returns True while the probe finds the process or a group member.
34
+ */
35
+ export declare function treeAlive(tree: RunTree, internals?: RunTreeInternals): boolean;
29
36
  /**
30
37
  * Wait until a terminated run's tree is gone, so the caller restores and
31
38
  * unlocks the profile only after the scripts it started stopped writing.
@@ -21,28 +21,34 @@ function targetOf(pid, grouped, platform) {
21
21
  return platform !== 'win32' && grouped ? -pid : pid;
22
22
  }
23
23
  /**
24
- * Wait until a terminated run's tree is gone, so the caller restores and
25
- * unlocks the profile only after the scripts it started stopped writing.
24
+ * Whether any member of a run's tree is still alive.
26
25
  * @param tree The run's process id and whether it leads its own group.
27
26
  * @param internals Injectable process operations.
28
- * @returns Fulfillment once no member remains, or the wait bound elapsed.
27
+ * @returns True while the probe finds the process or a group member.
29
28
  */
30
- export async function awaitTreeGone(tree, internals = {}) {
29
+ export function treeAlive(tree, internals = {}) {
31
30
  const pid = tree.pid;
32
31
  if (pid === undefined)
33
- return;
32
+ return false;
34
33
  const platform = internals.platform ?? process.platform;
35
34
  const alive = internals.alive ?? ((target) => { process.kill(target, 0); return true; });
36
- const remaining = () => {
37
- try {
38
- return alive(targetOf(pid, tree.grouped, platform));
39
- }
40
- catch {
41
- return false;
42
- }
43
- };
35
+ try {
36
+ return alive(targetOf(pid, tree.grouped, platform));
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /**
43
+ * Wait until a terminated run's tree is gone, so the caller restores and
44
+ * unlocks the profile only after the scripts it started stopped writing.
45
+ * @param tree The run's process id and whether it leads its own group.
46
+ * @param internals Injectable process operations.
47
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
48
+ */
49
+ export async function awaitTreeGone(tree, internals = {}) {
44
50
  const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
45
- while (remaining()) {
51
+ while (treeAlive(tree, internals)) {
46
52
  if (Date.now() >= deadline)
47
53
  return;
48
54
  await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-plugin-manager",
3
3
  "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools",
4
- "version": "0.1.7-rc.1",
4
+ "version": "0.1.7-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -59,23 +59,23 @@
59
59
  "execa": "^10.0.0",
60
60
  "yaml": "^2.9.0",
61
61
  "zod": "^4.4.3",
62
- "@deepseek-ai/dsh-app-boot": "0.1.7-rc.1",
63
- "@deepseek-ai/dsh-atomic-write": "0.1.7-rc.1",
62
+ "@deepseek-ai/dsh-atomic-write": "0.1.7-rc.2",
63
+ "@deepseek-ai/dsh-app-boot": "0.1.7-rc.2",
64
64
  "@deepseek-ai/schemastery": "~3.18.4",
65
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.1"
65
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@deepseek-ai/cordis": "~4.0.4",
69
69
  "@deepseek-ai/cordis-plugin-loader": "~1.0.5",
70
- "@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.1",
71
- "@deepseek-ai/dsh-subprocess": "0.1.7-rc.1",
72
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.1",
73
- "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
74
- "@deepseek-ai/dsh-hmr": "0.1.7-rc.1",
75
- "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
76
- "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.1",
77
- "@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
78
- "@deepseek-ai/dsh-user-approval": "0.1.7-rc.1"
70
+ "@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
72
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
73
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
74
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
75
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
76
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
77
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
78
+ "@deepseek-ai/dsh-hmr": "0.1.7-rc.2"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "@deepseek-ai/dsh-hmr": {
@@ -86,22 +86,22 @@
86
86
  }
87
87
  },
88
88
  "devDependencies": {
89
- "@deepseek-ai/dsh-package-manifest": "0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-package-manifest": "0.1.7-rc.2",
90
90
  "@deepseek-ai/cordis": "~4.0.4",
91
- "@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.1",
92
- "@deepseek-ai/dsh-subprocess": "0.1.7-rc.1",
93
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.1",
94
- "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
95
- "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
96
- "@deepseek-ai/dsh-hmr": "0.1.7-rc.1",
97
- "@deepseek-ai/dsh-brand": "0.1.7-rc.1",
98
- "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.1",
99
- "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
100
- "@deepseek-ai/dsh-session": "0.1.7-rc.1",
101
- "@deepseek-ai/dsh-session-projection": "0.1.7-rc.1",
102
- "@deepseek-ai/dsh-sandbox": "0.1.7-rc.1",
91
+ "@deepseek-ai/cordis-plugin-loader": "~1.0.5",
92
+ "@deepseek-ai/dsh-host-plugin-inventory": "0.1.7-rc.2",
93
+ "@deepseek-ai/dsh-subprocess": "0.1.7-rc.2",
94
+ "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
95
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
96
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
97
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
98
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
103
99
  "@deepseek-ai/cordis-plugin-timer": "~1.1.6",
104
- "@deepseek-ai/dsh-user-approval": "0.1.7-rc.1",
105
- "@deepseek-ai/cordis-plugin-loader": "~1.0.5"
100
+ "@deepseek-ai/dsh-hmr": "0.1.7-rc.2",
101
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
102
+ "@deepseek-ai/dsh-session-projection": "0.1.7-rc.2",
103
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
104
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
105
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2"
106
106
  }
107
107
  }