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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 } 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,15 +70,140 @@ 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
+ /** pnpm can install plugins through any direct-dependency field. */
156
+ function directDependencies(manifest) {
157
+ const extra = manifest;
158
+ return { ...extra.devDependencies, ...manifest.dependencies, ...extra.optionalDependencies };
159
+ }
160
+ /** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
161
+ function bundleComponentManifests(manifest, dir, anchor) {
162
+ const bundle = manifest.dsh?.bundle;
163
+ if (bundle === undefined)
164
+ return [];
165
+ const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file));
166
+ const names = new Set();
167
+ const visit = (rows) => {
168
+ for (const row of rows) {
169
+ if (row.group && Array.isArray(row.config))
170
+ visit(row.config);
171
+ if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':'))
172
+ continue;
173
+ const parts = row.name.split('/');
174
+ names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/'));
175
+ }
176
+ };
177
+ visit(composeEntries([patches.filter(patch => patch.insert !== undefined)]));
178
+ return [...names].flatMap((name) => {
179
+ let packageDir;
180
+ try {
181
+ packageDir = resolveBundleDir('dsh', name, anchor, dir);
182
+ }
183
+ catch (error) {
184
+ // Resolution errors for uninstalled or dynamic rows remain subject to the startup loader's checks.
185
+ void error;
186
+ return [];
187
+ }
188
+ return [readProfileManifest('dsh', packageDir)];
189
+ });
190
+ }
68
191
  /** Execute pnpm inside a profile whose caller already holds the profile write lock.
192
+ * Newly installed or updated direct dependencies are checked even when activation is disabled;
193
+ * an untouched dependency never blocks an unrelated operation and stays denied at startup.
194
+ * Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
69
195
  * @param context Launcher-owned profile and resolution locations.
70
196
  * @param args Pnpm arguments, before relative path anchoring.
71
197
  * @param options Output, activation and cancellation policy.
72
- * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
198
+ * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
199
+ * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
73
200
  */
74
201
  export async function runProfilePnpm(context, args, options) {
75
202
  const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
76
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'))]));
77
207
  const logRoot = join(dir, '.plugin-manager', 'logs');
78
208
  await mkdir(logRoot, { recursive: true, mode: 0o700 });
79
209
  const logDir = await mkdtemp(join(logRoot, 'operation-'));
@@ -81,60 +211,250 @@ export async function runProfilePnpm(context, args, options) {
81
211
  const log = await open(logPath, 'wx', 0o600);
82
212
  let output = Buffer.alloc(0);
83
213
  let truncated = false;
214
+ const append = (bytes) => {
215
+ output = Buffer.concat([output, bytes]);
216
+ if (output.length > options.outputBytes) {
217
+ truncated = true;
218
+ output = output.subarray(output.length - options.outputBytes);
219
+ }
220
+ };
221
+ const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env };
222
+ const restore = async () => {
223
+ for (const file of savedFiles) {
224
+ if (file.text === undefined)
225
+ await rm(file.path, { force: true });
226
+ else
227
+ await writeFileAtomic(file.path, file.text, { mode: 0o600 });
228
+ }
229
+ };
230
+ /** Packages a compatibility check refused; callers render them for their own surface. */
231
+ const incompatible = [];
232
+ const rejected = async (warnings, restoration) => {
233
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
234
+ await log.write(diagnostic);
235
+ options.onOutput?.(diagnostic, 'stderr');
236
+ append(Buffer.from(diagnostic));
237
+ await log.close();
238
+ return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible };
239
+ };
240
+ // An install command names the packages it adds, so their manifests are read and checked before
241
+ // pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
242
+ const preflight = [];
243
+ const exemptions = readProfileVersionExemptions(dir);
244
+ // The run's own registry flags, so the lookup asks the registry the installation will use.
245
+ const registryFlags = args.filter(argument => argument.startsWith('--registry='));
246
+ for (const raw of namedSpecs(args)) {
247
+ // A spec whose manifest cannot be read or validated is left to the run itself and to the check
248
+ // after installation, which reports what it could not validate.
249
+ try {
250
+ const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
251
+ if (manifest === undefined)
252
+ continue;
253
+ const issue = evaluatePluginCompatibility(manifest, exemptions);
254
+ if (issue !== undefined && !issue.exempted) {
255
+ preflight.push(pluginCompatibilityWarning(issue));
256
+ incompatible.push(incompatiblePlugin(issue));
257
+ }
258
+ }
259
+ catch (error) {
260
+ void error;
261
+ continue;
262
+ }
263
+ }
264
+ if (preflight.length > 0)
265
+ return rejected(preflight, 'nothing was installed');
84
266
  const cancellation = new AbortController();
267
+ // A service run captures output, so execa terminates the tree it leads when the
268
+ // run is killed: a lifecycle script outlives the pnpm process that started it.
269
+ // The CLI keeps the caller's process group, so an interrupt still reaches it.
270
+ const grouped = leadsOwnGroup(options.execution);
85
271
  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,
272
+ cwd: dir, env: environment, extendEnv: false, reject: false,
87
273
  stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
88
274
  stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
275
+ killDescendants: options.execution === 'service',
89
276
  buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
90
277
  ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
91
278
  });
92
279
  let writes = Promise.resolve();
280
+ /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
281
+ const control = { settled: false, stalled: false };
282
+ /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
283
+ let cut = false;
284
+ /** The first failure a reading hit before that cut, which the run still reports. */
285
+ let failure;
286
+ let idleTimer;
287
+ /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
288
+ const armIdle = () => {
289
+ if (control.settled || options.idleTimeoutMs === undefined)
290
+ return;
291
+ clearTimeout(idleTimer);
292
+ idleTimer = setTimeout(() => {
293
+ control.stalled = true;
294
+ // execa's kill reaches the whole tree of a service run, and escalates on its own.
295
+ child.kill();
296
+ }, options.idleTimeoutMs);
297
+ };
93
298
  const collect = async (stream, kind) => {
94
299
  try {
95
300
  for await (const chunk of stream) {
301
+ armIdle();
96
302
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
97
303
  writes = writes.then(async () => { await log.write(bytes); });
98
304
  await writes;
99
305
  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
- }
306
+ append(bytes);
105
307
  }
106
308
  }
107
309
  catch (error) {
108
- cancellation.abort();
310
+ // A reading this call cut short is not a failure the run hit, and the run has
311
+ // already exited, so there is nothing left for the cancellation to stop.
312
+ if (!cut) {
313
+ failure ??= error instanceof Error ? error : new Error(String(error));
314
+ cancellation.abort();
315
+ }
109
316
  throw error;
110
317
  }
111
318
  };
319
+ const collectors = [
320
+ ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
321
+ ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
322
+ ];
323
+ // The drain decides whether a failure surfaces, so each reading is claimed now:
324
+ // an unclaimed rejection would be reported as unhandled while the pipes drain.
325
+ for (const collector of collectors)
326
+ void collector.catch(() => { });
327
+ // Inherited descriptors hand pnpm the terminal, so there is no captured output
328
+ // for a silence bound to observe: it would fire on a healthy run.
329
+ if (collectors.length > 0)
330
+ armIdle();
112
331
  let exitCode;
113
332
  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;
333
+ // execa resolves its promise only once the piped stdio has ended, so the
334
+ // process's own exit — the run's completion — is read from the raw child. A
335
+ // spawn failure settles without one.
336
+ const settled = Promise.allSettled([child]);
337
+ await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled]);
338
+ control.settled = true;
339
+ clearTimeout(idleTimer);
340
+ // A stalled run stops its whole tree first, so the caller's rollback and lock
341
+ // release happen only after the scripts it started stopped writing.
342
+ if (control.stalled)
343
+ await awaitTreeGone({ pid: child.pid, grouped });
344
+ // A descendant that inherited the pipes can hold them open past the process;
345
+ // the tail drains under a bound instead of being awaited forever.
346
+ const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS);
347
+ if (drained) {
348
+ for (const stream of await Promise.allSettled(collectors))
349
+ if (stream.status === 'rejected')
350
+ throw stream.reason;
351
+ }
352
+ else {
353
+ // Cutting the tail short is this call's own end, not a failure the run hit;
354
+ // a failure from before the cut still surfaces, and the cut leaves a notice
355
+ // in the log because a classification may read an incomplete tail.
356
+ cut = true;
357
+ child.stdout?.destroy();
358
+ child.stderr?.destroy();
359
+ const notice = 'dsh: pnpm output was cut short after its process exited\n';
360
+ await log.write(notice);
361
+ // A failure from before the cut is the run's own and replaces the notice a
362
+ // caller would otherwise read; a rejection the cut itself causes is its end.
363
+ if (failure !== undefined)
364
+ throw failure;
365
+ options.onOutput?.(notice, 'stderr');
366
+ append(Buffer.from(notice));
367
+ }
368
+ const [completion] = await settled;
121
369
  if (completion.status === 'rejected')
122
370
  throw completion.reason;
123
371
  const result = completion.value;
124
372
  exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1);
373
+ if (control.stalled) {
374
+ const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
375
+ await log.write(notice);
376
+ options.onOutput?.(notice, 'stderr');
377
+ append(Buffer.from(notice));
378
+ }
125
379
  if (result.failed && output.length === 0) {
126
380
  const diagnostic = result.shortMessage ?? 'pnpm failed';
127
381
  await log.write(diagnostic);
128
382
  truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
129
383
  output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
130
384
  }
131
- if (exitCode === 0 && options.activateNewBundles !== false)
132
- await reconcile(before, dir, context.installAnchor, options);
385
+ // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
386
+ if (exitCode === 0 && !control.stalled) {
387
+ const after = readProfileManifest('dsh', dir);
388
+ const warnings = [];
389
+ for (const [name, spec] of Object.entries(directDependencies(after))) {
390
+ const packageDir = join(dir, 'node_modules', name);
391
+ const installed = optionalFile(join(packageDir, 'package.json'));
392
+ if (installed === undefined)
393
+ continue;
394
+ // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
395
+ const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
396
+ const found = [];
397
+ const issues = [];
398
+ try {
399
+ const manifest = readProfileManifest('dsh', packageDir);
400
+ for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
401
+ const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
402
+ if (issue !== undefined && !issue.exempted) {
403
+ found.push(pluginCompatibilityWarning(issue));
404
+ issues.push(incompatiblePlugin(issue));
405
+ }
406
+ }
407
+ }
408
+ catch (error) {
409
+ found.push(`Cannot validate installed package ${name}: ${String(error)}`);
410
+ }
411
+ if (found.length === 0)
412
+ continue;
413
+ if (!untouched) {
414
+ warnings.push(...found);
415
+ incompatible.push(...issues);
416
+ }
417
+ else {
418
+ 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`;
419
+ await log.write(notice);
420
+ options.onOutput?.(notice, 'stderr');
421
+ }
422
+ }
423
+ if (warnings.length > 0) {
424
+ // A bundle component's peers need installed contents, so this rejection lands after pnpm
425
+ // replaced the tree: restore the files, then reinstall the restored lockfile so the version
426
+ // that worked before this run keeps loading. A profile that had no lockfile is reinstalled
427
+ // from its restored manifest without creating one, which removes what this run added.
428
+ await restore();
429
+ const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
430
+ const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
431
+ const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
432
+ cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
433
+ ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
434
+ });
435
+ exitCode = 1;
436
+ const restoration = repaired.exitCode === 0
437
+ ? 'restored package.json, pnpm-lock.yaml, and node_modules'
438
+ : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
439
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
440
+ await log.write(diagnostic);
441
+ options.onOutput?.(diagnostic, 'stderr');
442
+ append(Buffer.from(diagnostic));
443
+ }
444
+ else if (options.activateNewBundles !== false) {
445
+ await reconcile(before, dir, context.installAnchor, options);
446
+ }
447
+ }
133
448
  }
134
449
  finally {
450
+ control.settled = true;
451
+ clearTimeout(idleTimer);
135
452
  await log.close();
136
453
  }
137
- return { exitCode, output: output.toString('utf8'), truncated, logPath };
454
+ return {
455
+ exitCode, output: output.toString('utf8'), truncated, logPath,
456
+ ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {},
457
+ };
138
458
  }
139
459
  /** Initialize and run the dsh plugin command with the same write lock as the service.
140
460
  * @param context Launcher-owned locations.
@@ -0,0 +1,37 @@
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
+ * Wait until a terminated run's tree is gone, so the caller restores and
31
+ * unlocks the profile only after the scripts it started stopped writing.
32
+ * @param tree The run's process id and whether it leads its own group.
33
+ * @param internals Injectable process operations.
34
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
35
+ */
36
+ export declare function awaitTreeGone(tree: RunTree, internals?: RunTreeInternals): Promise<void>;
37
+ //# sourceMappingURL=run-tree.d.ts.map
@@ -0,0 +1,51 @@
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
+ * 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.
26
+ * @param tree The run's process id and whether it leads its own group.
27
+ * @param internals Injectable process operations.
28
+ * @returns Fulfillment once no member remains, or the wait bound elapsed.
29
+ */
30
+ export async function awaitTreeGone(tree, internals = {}) {
31
+ const pid = tree.pid;
32
+ if (pid === undefined)
33
+ return;
34
+ const platform = internals.platform ?? process.platform;
35
+ 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
+ };
44
+ const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
45
+ while (remaining()) {
46
+ if (Date.now() >= deadline)
47
+ return;
48
+ await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS));
49
+ }
50
+ }
51
+ //# 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;
@@ -6,10 +6,20 @@ export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types
6
6
  import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types';
7
7
  /** Reasons a profile control cannot modify its target. */
8
8
  export type ReadOnlyReason = 'management-required' | 'unaddressable';
9
+ /** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */
10
+ export interface IncompatiblePlugin {
11
+ name: string;
12
+ version: string;
13
+ runtimeVersion: string;
14
+ /** Only the DSH peer ranges the running version does not satisfy. */
15
+ peers: Record<string, string>;
16
+ }
9
17
  /** Localizable management failure and optional external diagnostic. */
10
18
  export interface ManagementError {
11
- code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'operation-error';
19
+ code: ReadOnlyReason | 'unknown-plugin' | 'invalid-spec' | 'ambiguous-install' | 'not-bundle' | 'not-removable' | 'stop-profile' | 'bundle-in-use' | 'stale-approval' | 'incompatible-version' | 'operation-error';
12
20
  diagnostic?: string;
21
+ /** Present with `incompatible-version`: the packages the running DSH version rejects. */
22
+ incompatible?: IncompatiblePlugin[];
13
23
  }
14
24
  /** One running-profile entry and its persistent control availability. */
15
25
  export type PluginInfo = PluginInventoryEntry & ({
@@ -64,9 +74,9 @@ export interface PluginRegistries {
64
74
  /** The URL pnpm's own configuration names in the profile, read from pnpm; null when it could not be read. */
65
75
  readonly resolved: string | null;
66
76
  }
67
- /** How a pnpm run failed, read off how it ended and what it printed. */
77
+ /** How a package operation failed, read off how it ended and what it printed. */
68
78
  export type PluginInstallFailureKind = 'pnpm-missing' | 'timeout' | 'not-found' | 'no-matching-version' | 'network' | 'disk-full' | 'permission' | 'build-blocked' | 'integrity' | 'unknown';
69
- /** Pnpm completion, including a retrieval path for unabridged diagnostics. */
79
+ /** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */
70
80
  export interface PackageResult {
71
81
  exitCode: number;
72
82
  output: string;
@@ -74,6 +84,10 @@ export interface PackageResult {
74
84
  logPath: string;
75
85
  /** Present when the run failed: what kind of failure its exit and output describe. */
76
86
  kind?: PluginInstallFailureKind;
87
+ /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */
88
+ timedOut?: boolean;
89
+ /** Present when a compatibility check refused the run: the packages the running DSH version rejects. */
90
+ incompatible?: IncompatiblePlugin[];
77
91
  }
78
92
  /** Persisted change and independently observed application outcome. */
79
93
  export interface ChangeResult {