@deepseek-ai/dsh-plugin-manager 0.1.7-alpha.2 → 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.
@@ -1,11 +1,16 @@
1
1
  /** Shared profile package operations used by dsh plugin and the running manager. */
2
- import { existsSync } from 'node:fs';
3
- import { mkdir, mkdtemp, open } from 'node:fs/promises';
2
+ import { once } from 'node:events';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { mkdir, mkdtemp, open, rm } from 'node:fs/promises';
4
5
  import { join, resolve } from 'node:path';
5
6
  import { execa } from 'execa';
6
7
  import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
7
- import { DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, } from '@deepseek-ai/dsh-app-boot';
8
+ import { DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions, evaluatePluginCompatibility, pluginCompatibilityWarning, } from '@deepseek-ai/dsh-app-boot';
8
9
  import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess';
10
+ import { parseInstallSpec } from "./install-spec.js";
11
+ import { awaitTreeGone, leadsOwnGroup, treeAlive } from "./run-tree.js";
12
+ import { incompatiblePlugin } from "./failure.js";
13
+ export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot';
9
14
  /** Resolve relative package specs against the caller's directory.
10
15
  * @param argument One pnpm argument.
11
16
  * @param cwd Invocation directory, never the profile directory.
@@ -65,76 +70,452 @@ async function reconcile(before, dir, anchor, options) {
65
70
  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } };
66
71
  await saveManifest(dir, after);
67
72
  }
73
+ /**
74
+ * How long the pipes keep draining after their process exited, as a fixed part of
75
+ * finishing a run rather than a deployment knob: a descendant that inherited them
76
+ * holds them open, and the tail a failure classification reads is written by then.
77
+ */
78
+ const DRAIN_AFTER_EXIT_MS = 2_000;
79
+ /** Whether every collector finished within `ms`.
80
+ * @param collectors The pipe readers racing the bound.
81
+ * @param ms The longest wait, in milliseconds.
82
+ * @returns True when all collectors settled in time.
83
+ */
84
+ async function drainWithin(collectors, ms) {
85
+ if (collectors.length === 0)
86
+ return true;
87
+ let timer;
88
+ try {
89
+ return await Promise.race([
90
+ Promise.allSettled(collectors).then(() => true),
91
+ new Promise((resolve) => {
92
+ timer = setTimeout(() => { resolve(false); }, ms);
93
+ }),
94
+ ]);
95
+ }
96
+ finally {
97
+ clearTimeout(timer);
98
+ }
99
+ }
100
+ /** Install commands that take the packages to install as positionals. */
101
+ const INSTALL_COMMANDS = new Set(['add', 'install', 'i']);
102
+ /** Bound on a pre-install registry lookup when the caller names none. */
103
+ const LOOKUP_TIMEOUT_MS = 20_000;
104
+ /** Package specs an install command names explicitly, in order. */
105
+ function namedSpecs(args) {
106
+ const index = args.findIndex(argument => !argument.startsWith('-'));
107
+ const command = index < 0 ? undefined : args[index];
108
+ if (command === undefined || !INSTALL_COMMANDS.has(command))
109
+ return [];
110
+ return args.slice(index + 1).filter(argument => !argument.startsWith('-'));
111
+ }
112
+ /** The manifest a named spec would install, read without installing it.
113
+ * A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the
114
+ * range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the
115
+ * check after installation is what judges it.
116
+ * @param dir Profile directory the lookup runs in.
117
+ * @param spec Anchored install spec.
118
+ * @param options Pnpm executable, prefix arguments, the caller's bound and signal.
119
+ * @param environment Environment of the caller's pnpm invocations.
120
+ * @param flags Flags of the run itself, so the lookup asks the registry that run will use.
121
+ * @returns The package manifest, or undefined when reading it would need the installation itself.
122
+ */
123
+ async function namedSpecManifest(dir, spec, options, environment, flags) {
124
+ const parsed = parseInstallSpec(spec);
125
+ if (parsed.kind === 'path') {
126
+ const filename = join(parsed.path, 'package.json');
127
+ return existsSync(filename) ? JSON.parse(readFileSync(filename, 'utf8')) : undefined;
128
+ }
129
+ if (parsed.kind !== 'registry')
130
+ return undefined;
131
+ const viewed = await execa(options.command ?? 'pnpm', [
132
+ ...options.args ?? [], 'view', parsed.spec, 'name', 'version', 'peerDependencies', '--json',
133
+ ...flags, '--config.fetch-retries=0',
134
+ ], {
135
+ cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
136
+ ...options.signal === undefined ? {} : { cancelSignal: options.signal },
137
+ timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS,
138
+ });
139
+ if (viewed.exitCode !== 0)
140
+ return undefined;
141
+ const value = JSON.parse(viewed.stdout);
142
+ return (Array.isArray(value) ? value.at(-1) : value);
143
+ }
144
+ /** Missing installed packages are repairable; their absence is part of the before/after comparison. */
145
+ function optionalFile(path) {
146
+ try {
147
+ return readFileSync(path, 'utf8');
148
+ }
149
+ catch (error) {
150
+ if (error.code === 'ENOENT')
151
+ return undefined;
152
+ throw error;
153
+ }
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
+ }
200
+ /** pnpm can install plugins through any direct-dependency field. */
201
+ function directDependencies(manifest) {
202
+ const extra = manifest;
203
+ return { ...extra.devDependencies, ...manifest.dependencies, ...extra.optionalDependencies };
204
+ }
205
+ /** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
206
+ function bundleComponentManifests(manifest, dir, anchor) {
207
+ const bundle = manifest.dsh?.bundle;
208
+ if (bundle === undefined)
209
+ return [];
210
+ const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file));
211
+ const names = new Set();
212
+ const visit = (rows) => {
213
+ for (const row of rows) {
214
+ if (row.group && Array.isArray(row.config))
215
+ visit(row.config);
216
+ if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':'))
217
+ continue;
218
+ const parts = row.name.split('/');
219
+ names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/'));
220
+ }
221
+ };
222
+ visit(composeEntries([patches.filter(patch => patch.insert !== undefined)]));
223
+ return [...names].flatMap((name) => {
224
+ let packageDir;
225
+ try {
226
+ packageDir = resolveBundleDir('dsh', name, anchor, dir);
227
+ }
228
+ catch (error) {
229
+ // Resolution errors for uninstalled or dynamic rows remain subject to the startup loader's checks.
230
+ void error;
231
+ return [];
232
+ }
233
+ return [readProfileManifest('dsh', packageDir)];
234
+ });
235
+ }
68
236
  /** Execute pnpm inside a profile whose caller already holds the profile write lock.
237
+ * Newly installed or updated direct dependencies are checked even when activation is disabled;
238
+ * an untouched dependency never blocks an unrelated operation and stays denied at startup.
239
+ * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
69
240
  * @param context Launcher-owned profile and resolution locations.
70
241
  * @param args Pnpm arguments, before relative path anchoring.
71
242
  * @param options Output, activation and cancellation policy.
72
- * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
243
+ * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
244
+ * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
73
245
  */
74
246
  export async function runProfilePnpm(context, args, options) {
75
247
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
76
- const before = readProfileManifest('dsh', dir);
248
+ // Before any profile file is read: a recorded run that is still active may be rewriting them.
249
+ const active = await activeRecordedRun(dir);
77
250
  const logRoot = join(dir, '.plugin-manager', 'logs');
78
251
  await mkdir(logRoot, { recursive: true, mode: 0o700 });
79
252
  const logDir = await mkdtemp(join(logRoot, 'operation-'));
80
253
  const logPath = join(logDir, 'pnpm.log');
81
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'))]));
82
266
  let output = Buffer.alloc(0);
83
267
  let truncated = false;
268
+ const append = (bytes) => {
269
+ output = Buffer.concat([output, bytes]);
270
+ if (output.length > options.outputBytes) {
271
+ truncated = true;
272
+ output = output.subarray(output.length - options.outputBytes);
273
+ }
274
+ };
275
+ const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env };
276
+ const restore = async () => {
277
+ for (const file of savedFiles) {
278
+ if (file.text === undefined)
279
+ await rm(file.path, { force: true });
280
+ else
281
+ await writeFileAtomic(file.path, file.text, { mode: 0o600 });
282
+ }
283
+ };
284
+ /** Packages a compatibility check refused; callers render them for their own surface. */
285
+ const incompatible = [];
286
+ const rejected = async (warnings, restoration) => {
287
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
288
+ await log.write(diagnostic);
289
+ options.onOutput?.(diagnostic, 'stderr');
290
+ append(Buffer.from(diagnostic));
291
+ await log.close();
292
+ return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible };
293
+ };
294
+ // An install command names the packages it adds, so their manifests are read and checked before
295
+ // pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
296
+ const preflight = [];
297
+ const exemptions = readProfileVersionExemptions(dir);
298
+ // The run's own registry flags, so the lookup asks the registry the installation will use.
299
+ const registryFlags = args.filter(argument => argument.startsWith('--registry='));
300
+ for (const raw of namedSpecs(args)) {
301
+ // A spec whose manifest cannot be read or validated is left to the run itself and to the check
302
+ // after installation, which reports what it could not validate.
303
+ try {
304
+ const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
305
+ if (manifest === undefined)
306
+ continue;
307
+ const issue = evaluatePluginCompatibility(manifest, exemptions);
308
+ if (issue !== undefined && !issue.exempted) {
309
+ preflight.push(pluginCompatibilityWarning(issue));
310
+ incompatible.push(incompatiblePlugin(issue));
311
+ }
312
+ }
313
+ catch (error) {
314
+ void error;
315
+ continue;
316
+ }
317
+ }
318
+ if (preflight.length > 0)
319
+ return rejected(preflight, 'nothing was installed');
84
320
  const cancellation = new AbortController();
321
+ // A service run captures output, so execa terminates the tree it leads when the
322
+ // run is killed: a lifecycle script outlives the pnpm process that started it.
323
+ // The CLI keeps the caller's process group, so an interrupt still reaches it.
324
+ const grouped = leadsOwnGroup(options.execution);
85
325
  const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
86
- cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
326
+ cwd: dir, env: environment, extendEnv: false, reject: false,
87
327
  stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
88
328
  stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
329
+ killDescendants: options.execution === 'service',
89
330
  buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
90
331
  ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
91
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);
92
335
  let writes = Promise.resolve();
336
+ /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
337
+ const control = { settled: false, stalled: false };
338
+ /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
339
+ let cut = false;
340
+ /** The first failure a reading hit before that cut, which the run still reports. */
341
+ let failure;
342
+ let idleTimer;
343
+ /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
344
+ const armIdle = () => {
345
+ if (control.settled || options.idleTimeoutMs === undefined)
346
+ return;
347
+ clearTimeout(idleTimer);
348
+ idleTimer = setTimeout(() => {
349
+ control.stalled = true;
350
+ // execa's kill reaches the whole tree of a service run, and escalates on its own.
351
+ child.kill();
352
+ }, options.idleTimeoutMs);
353
+ };
93
354
  const collect = async (stream, kind) => {
94
355
  try {
95
356
  for await (const chunk of stream) {
357
+ armIdle();
96
358
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
97
359
  writes = writes.then(async () => { await log.write(bytes); });
98
360
  await writes;
99
361
  options.onOutput?.(bytes.toString('utf8'), kind);
100
- output = Buffer.concat([output, bytes]);
101
- if (output.length > options.outputBytes) {
102
- truncated = true;
103
- output = output.subarray(output.length - options.outputBytes);
104
- }
362
+ append(bytes);
105
363
  }
106
364
  }
107
365
  catch (error) {
108
- cancellation.abort();
366
+ // A reading this call cut short is not a failure the run hit, and the run has
367
+ // already exited, so there is nothing left for the cancellation to stop.
368
+ if (!cut) {
369
+ failure ??= error instanceof Error ? error : new Error(String(error));
370
+ cancellation.abort();
371
+ }
109
372
  throw error;
110
373
  }
111
374
  };
375
+ const collectors = [
376
+ ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
377
+ ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
378
+ ];
379
+ // The drain decides whether a failure surfaces, so each reading is claimed now:
380
+ // an unclaimed rejection would be reported as unhandled while the pipes drain.
381
+ for (const collector of collectors)
382
+ void collector.catch(() => { });
383
+ // Inherited descriptors hand pnpm the terminal, so there is no captured output
384
+ // for a silence bound to observe: it would fire on a healthy run.
385
+ if (collectors.length > 0)
386
+ armIdle();
112
387
  let exitCode;
113
388
  try {
114
- const [completion, ...streams] = await Promise.allSettled([child,
115
- ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
116
- ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
117
- ]);
118
- for (const stream of streams)
119
- if (stream.status === 'rejected')
120
- throw stream.reason;
389
+ await recordRun(dir, { pid: child.pid, grouped });
390
+ // execa resolves its promise only once the piped stdio has ended, so the
391
+ // process's own exit — the run's completion — is read from the raw child. A
392
+ // spawn failure settles without one.
393
+ const settled = Promise.allSettled([child]);
394
+ await Promise.race([exited, settled]);
395
+ control.settled = true;
396
+ clearTimeout(idleTimer);
397
+ // A stalled run stops its whole tree first, so the caller's rollback and lock
398
+ // release happen only after the scripts it started stopped writing.
399
+ if (control.stalled)
400
+ await awaitTreeGone({ pid: child.pid, grouped });
401
+ // A descendant that inherited the pipes can hold them open past the process;
402
+ // the tail drains under a bound instead of being awaited forever.
403
+ const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS);
404
+ if (drained) {
405
+ for (const stream of await Promise.allSettled(collectors))
406
+ if (stream.status === 'rejected')
407
+ throw stream.reason;
408
+ }
409
+ else {
410
+ // Cutting the tail short is this call's own end, not a failure the run hit;
411
+ // a failure from before the cut still surfaces, and the cut leaves a notice
412
+ // in the log because a classification may read an incomplete tail.
413
+ cut = true;
414
+ child.stdout?.destroy();
415
+ child.stderr?.destroy();
416
+ const notice = 'dsh: pnpm output was cut short after its process exited\n';
417
+ await log.write(notice);
418
+ // A failure from before the cut is the run's own and replaces the notice a
419
+ // caller would otherwise read; a rejection the cut itself causes is its end.
420
+ if (failure !== undefined)
421
+ throw failure;
422
+ options.onOutput?.(notice, 'stderr');
423
+ append(Buffer.from(notice));
424
+ }
425
+ const [completion] = await settled;
121
426
  if (completion.status === 'rejected')
122
427
  throw completion.reason;
123
428
  const result = completion.value;
124
429
  exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1);
430
+ if (control.stalled) {
431
+ const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
432
+ await log.write(notice);
433
+ options.onOutput?.(notice, 'stderr');
434
+ append(Buffer.from(notice));
435
+ }
125
436
  if (result.failed && output.length === 0) {
126
437
  const diagnostic = result.shortMessage ?? 'pnpm failed';
127
438
  await log.write(diagnostic);
128
439
  truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
129
440
  output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
130
441
  }
131
- if (exitCode === 0 && options.activateNewBundles !== false)
132
- await reconcile(before, dir, context.installAnchor, options);
442
+ // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
443
+ if (exitCode === 0 && !control.stalled) {
444
+ const after = readProfileManifest('dsh', dir);
445
+ const warnings = [];
446
+ for (const [name, spec] of Object.entries(directDependencies(after))) {
447
+ const packageDir = join(dir, 'node_modules', name);
448
+ const installed = optionalFile(join(packageDir, 'package.json'));
449
+ if (installed === undefined)
450
+ continue;
451
+ // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
452
+ const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
453
+ const found = [];
454
+ const issues = [];
455
+ try {
456
+ const manifest = readProfileManifest('dsh', packageDir);
457
+ for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
458
+ const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
459
+ if (issue !== undefined && !issue.exempted) {
460
+ found.push(pluginCompatibilityWarning(issue));
461
+ issues.push(incompatiblePlugin(issue));
462
+ }
463
+ }
464
+ }
465
+ catch (error) {
466
+ found.push(`Cannot validate installed package ${name}: ${String(error)}`);
467
+ }
468
+ if (found.length === 0)
469
+ continue;
470
+ if (!untouched) {
471
+ warnings.push(...found);
472
+ incompatible.push(...issues);
473
+ }
474
+ else {
475
+ const notice = `\ndsh: warning: ${found.join('\n')}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`;
476
+ await log.write(notice);
477
+ options.onOutput?.(notice, 'stderr');
478
+ }
479
+ }
480
+ if (warnings.length > 0) {
481
+ // A bundle component's peers need installed contents, so this rejection lands after pnpm
482
+ // replaced the tree: restore the files, then reinstall the restored lockfile so the version
483
+ // that worked before this run keeps loading. A profile that had no lockfile is reinstalled
484
+ // from its restored manifest without creating one, which removes what this run added.
485
+ await restore();
486
+ const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
487
+ const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
488
+ const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
489
+ cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
490
+ ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
491
+ });
492
+ await recordRun(dir, { pid: repairing.pid, grouped: false });
493
+ const repaired = await repairing;
494
+ exitCode = 1;
495
+ const restoration = repaired.exitCode === 0
496
+ ? 'restored package.json, pnpm-lock.yaml, and node_modules'
497
+ : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
498
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
499
+ await log.write(diagnostic);
500
+ options.onOutput?.(diagnostic, 'stderr');
501
+ append(Buffer.from(diagnostic));
502
+ }
503
+ else if (options.activateNewBundles !== false) {
504
+ await reconcile(before, dir, context.installAnchor, options);
505
+ }
506
+ }
133
507
  }
134
508
  finally {
509
+ control.settled = true;
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 });
135
513
  await log.close();
136
514
  }
137
- return { exitCode, output: output.toString('utf8'), truncated, logPath };
515
+ return {
516
+ exitCode, output: output.toString('utf8'), truncated, logPath,
517
+ ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {},
518
+ };
138
519
  }
139
520
  /** Initialize and run the dsh plugin command with the same write lock as the service.
140
521
  * @param context Launcher-owned locations.
@@ -0,0 +1,44 @@
1
+ /** Waiting for one package run's process tree to disappear before its caller touches the profile. */
2
+ /** The process (or process group) one run occupies. */
3
+ export interface RunTree {
4
+ /** The run's process id, when its spawn published one. */
5
+ pid: number | undefined;
6
+ /** Whether the run leads its own process group, which is what a POSIX group probe addresses. */
7
+ grouped: boolean;
8
+ }
9
+ /** Injectable process operations, so every platform's probing is testable on any host. */
10
+ export interface RunTreeInternals {
11
+ /** Host platform override for target decisions. */
12
+ platform?: NodeJS.Platform;
13
+ /** Liveness probe override, defaulting to `process.kill(target, 0)`. */
14
+ alive?: (target: number) => boolean;
15
+ /** Wait bound override, defaulting to the tree wait constant. */
16
+ waitMs?: number;
17
+ }
18
+ /**
19
+ * Whether one run leads its own process group, which is the target a POSIX
20
+ * liveness probe addresses for the whole tree. A run that captures output is
21
+ * spawned as its own group leader, because its tree is terminated as a unit; a
22
+ * run that inherits the caller's descriptors keeps the caller's group, so an
23
+ * interrupt still reaches it.
24
+ * @param execution Whether the run captures output or inherits the caller's descriptors.
25
+ * @param platform Host platform deciding how a tree is addressed.
26
+ * @returns True when the run leads its own process group.
27
+ */
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;
36
+ /**
37
+ * Wait until a terminated run's tree is gone, so the caller restores and
38
+ * unlocks the profile only after the scripts it started stopped writing.
39
+ * @param tree The run's process id and whether it leads its own group.
40
+ * @param internals Injectable process operations.
41
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
42
+ */
43
+ export declare function awaitTreeGone(tree: RunTree, internals?: RunTreeInternals): Promise<void>;
44
+ //# sourceMappingURL=run-tree.d.ts.map
@@ -0,0 +1,57 @@
1
+ /** Waiting for one package run's process tree to disappear before its caller touches the profile. */
2
+ /** How long a terminated tree may take to disappear before the caller stops waiting. */
3
+ const TREE_WAIT_MS = 5_000;
4
+ /** Poll cadence while waiting for a terminated tree to disappear. */
5
+ const TREE_POLL_MS = 15;
6
+ /**
7
+ * Whether one run leads its own process group, which is the target a POSIX
8
+ * liveness probe addresses for the whole tree. A run that captures output is
9
+ * spawned as its own group leader, because its tree is terminated as a unit; a
10
+ * run that inherits the caller's descriptors keeps the caller's group, so an
11
+ * interrupt still reaches it.
12
+ * @param execution Whether the run captures output or inherits the caller's descriptors.
13
+ * @param platform Host platform deciding how a tree is addressed.
14
+ * @returns True when the run leads its own process group.
15
+ */
16
+ export function leadsOwnGroup(execution, platform = process.platform) {
17
+ return execution === 'service' && platform !== 'win32';
18
+ }
19
+ /** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */
20
+ function targetOf(pid, grouped, platform) {
21
+ return platform !== 'win32' && grouped ? -pid : pid;
22
+ }
23
+ /**
24
+ * Whether any member of a run's tree is still alive.
25
+ * @param tree The run's process id and whether it leads its own group.
26
+ * @param internals Injectable process operations.
27
+ * @returns True while the probe finds the process or a group member.
28
+ */
29
+ export function treeAlive(tree, internals = {}) {
30
+ const pid = tree.pid;
31
+ if (pid === undefined)
32
+ return false;
33
+ const platform = internals.platform ?? process.platform;
34
+ const alive = internals.alive ?? ((target) => { process.kill(target, 0); return true; });
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 = {}) {
50
+ const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
51
+ while (treeAlive(tree, internals)) {
52
+ if (Date.now() >= deadline)
53
+ return;
54
+ await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS));
55
+ }
56
+ }
57
+ //# sourceMappingURL=run-tree.js.map
@@ -2,6 +2,7 @@
2
2
  import { assertNever } from '@deepseek-ai/dsh-util-values';
3
3
  import { approveEscalation } from '@deepseek-ai/dsh-sandbox';
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools';
5
+ import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot';
5
6
  /** Required services for the management tool. */
6
7
  export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
7
8
  /** Register one management tool for discovery and the four persistent actions.
@@ -10,11 +11,13 @@ export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
10
11
  export function apply(ctx) {
11
12
  ctx.tools.register(defineTool({
12
13
  name: 'plugin_manager',
13
- description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.',
14
+ description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart. Incompatible DSH peer dependencies block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.',
14
15
  parameters: {
15
- action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle'], description: 'Management operation.' },
16
+ action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' },
16
17
  target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action.' },
17
- enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation.' },
18
+ enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.' },
19
+ runtimeVersion: { type: 'string', description: 'For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.' },
20
+ acceptRisk: { type: 'boolean', description: 'For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.' },
18
21
  approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
19
22
  registry: { type: 'string', description: 'For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.' },
20
23
  offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
@@ -34,6 +37,13 @@ export function apply(ctx) {
34
37
  exec.signal.throwIfAborted();
35
38
  const manager = ctx.pluginManager;
36
39
  switch (args.action) {
40
+ case 'list_version_exemptions':
41
+ return JSON.stringify({ runtimeVersion: getDshRuntimeVersion(), ...manager.listVersionExemptions() });
42
+ case 'set_version_exemption':
43
+ if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) {
44
+ throw new Error('target package-name@version, runtimeVersion, and enabled are required');
45
+ }
46
+ return JSON.stringify(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk));
37
47
  case 'list_plugins':
38
48
  case 'list_bundles': {
39
49
  const offset = args.offset ?? 0;