dsh-plugin-local-infer 0.1.0 → 0.1.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.
Files changed (73) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +8 -1
  3. package/cordis.patch.yml +6 -6
  4. package/docs/screenshot-20261014.png +0 -0
  5. package/docs/zh/README.md +100 -0
  6. package/dsh/client.js +644 -143
  7. package/dsh/host.js +1 -1
  8. package/package.json +14 -11
  9. package/packages/core/lib/adapter.d.ts +75 -4
  10. package/packages/core/lib/debug.d.ts +1 -1
  11. package/packages/core/lib/debug.js +1 -1
  12. package/packages/core/lib/index.d.ts +3 -2
  13. package/packages/core/lib/index.js +2 -1
  14. package/packages/core/lib/launch-command.d.ts +13 -0
  15. package/packages/core/lib/launch-command.js +64 -0
  16. package/packages/core/lib/models.d.ts +2 -1
  17. package/packages/core/lib/models.js +10 -1
  18. package/packages/core/lib/status.d.ts +9 -1
  19. package/packages/core/lib/status.js +6 -1
  20. package/packages/core/package.json +1 -1
  21. package/packages/providers/lib/acquire.d.ts +125 -0
  22. package/packages/providers/lib/acquire.js +325 -0
  23. package/packages/providers/lib/adapters.d.ts +43 -6
  24. package/packages/providers/lib/adapters.js +386 -58
  25. package/packages/providers/lib/debug.d.ts +1 -1
  26. package/packages/providers/lib/debug.js +1 -1
  27. package/packages/providers/lib/deps.d.ts +6 -4
  28. package/packages/providers/lib/deps.js +17 -3
  29. package/packages/providers/lib/engines.d.ts +6 -5
  30. package/packages/providers/lib/engines.js +12 -4
  31. package/packages/providers/lib/guidelines.d.ts +2 -2
  32. package/packages/providers/lib/guidelines.js +17 -1
  33. package/packages/providers/lib/hyperqwen.d.ts +2 -2
  34. package/packages/providers/lib/hyperqwen.js +1 -1
  35. package/packages/providers/lib/index.d.ts +10 -4
  36. package/packages/providers/lib/index.js +9 -5
  37. package/packages/providers/lib/infernix.d.ts +170 -0
  38. package/packages/providers/lib/infernix.js +671 -0
  39. package/packages/providers/lib/llamacpp.d.ts +2 -2
  40. package/packages/providers/lib/llamacpp.js +1 -1
  41. package/packages/providers/lib/model-defaults.d.ts +7 -5
  42. package/packages/providers/lib/model-defaults.js +28 -4
  43. package/packages/providers/lib/ninfer.d.ts +9 -2
  44. package/packages/providers/lib/ninfer.js +10 -3
  45. package/packages/providers/lib/platform.d.ts +31 -0
  46. package/packages/providers/lib/platform.js +58 -0
  47. package/packages/providers/lib/presets.d.ts +2 -2
  48. package/packages/providers/lib/presets.js +1 -1
  49. package/packages/providers/lib/strata.d.ts +2 -2
  50. package/packages/providers/lib/strata.js +1 -1
  51. package/packages/providers/lib/vram.d.ts +2 -2
  52. package/packages/providers/lib/vram.js +1 -1
  53. package/packages/providers/package.json +3 -3
  54. package/packages/service/lib/index.d.ts +63 -20
  55. package/packages/service/lib/index.js +279 -43
  56. package/packages/service/lib/types.d.ts +44 -2
  57. package/packages/service/package.json +2 -2
  58. package/packages/tool/lib/index.d.ts +1 -1
  59. package/packages/tool/lib/index.js +1 -1
  60. package/packages/tool/package.json +3 -3
  61. package/packages/ui/lib/deps.d.ts +2 -2
  62. package/packages/ui/lib/deps.js +1 -1
  63. package/packages/ui/lib/form.d.ts +2 -2
  64. package/packages/ui/lib/form.js +1 -1
  65. package/packages/ui/lib/index.d.ts +1 -1
  66. package/packages/ui/lib/index.js +1 -1
  67. package/packages/ui/lib/meter.d.ts +2 -2
  68. package/packages/ui/lib/meter.js +1 -1
  69. package/packages/ui/lib/route.d.ts +84 -13
  70. package/packages/ui/lib/route.js +496 -56
  71. package/packages/ui/lib/warnings.d.ts +2 -2
  72. package/packages/ui/lib/warnings.js +1 -1
  73. package/packages/ui/package.json +5 -5
@@ -15,7 +15,7 @@
15
15
  * Phase 6 over the dsh settings seam, and `resolveDefaults` and
16
16
  * `fetchPreset` by Phase 7 over the core defaults resolver and the
17
17
  * mod-search bridge.
18
- * @module @dsh/dsh-plugin-local-infer/service
18
+ * @module dsh-plugin-local-infer/service
19
19
  */
20
20
  import { exec, execFile, spawn } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -46,6 +46,14 @@ export class LocalInferError extends Error {
46
46
  * "not available" instead.
47
47
  */
48
48
  const PROBE_TIMEOUT_MS = 5000;
49
+ /**
50
+ * The adopted-process poll interval. A re-adopted engine (a surviving process
51
+ * with no ChildProcess after a DSH restart) is watched for death through a
52
+ * liveness probe on this cadence: fast enough that a pkill settles the
53
+ * provider's state within seconds, slow enough that a signal-0 send per
54
+ * watched engine is free.
55
+ */
56
+ const PROCESS_WATCH_MS = 5000;
49
57
  /**
50
58
  * `ctx.localInfer`. Holds the engine, preset, guideline, and VramModel
51
59
  * reservations; the registries are read per call so a provider that unloads
@@ -72,6 +80,25 @@ export class LocalInfer extends Service {
72
80
  // restart, so a fresh start tracks only the launches it made itself. See
73
81
  // PLAN.md for the PID-cache note: a full PID cache is a future refinement.
74
82
  launchedModels = new Map();
83
+ // The live ports tracked per launched engine (launches and adopted re-links
84
+ // both register). The death handler clears the engine's plugin-launched flag
85
+ // only when its last live port dies, so one provider's engine stopping does
86
+ // not un-flag a second provider's still-running engine on another port.
87
+ launchedPorts = new Map();
88
+ // The poll watchers for processes adopted from stored launch records: a
89
+ // re-adopted engine has no ChildProcess to listen on after a DSH restart, so
90
+ // a liveness poll stands in for the exit event. Keyed by port; the timers
91
+ // are unref'd so a watched engine never keeps the host or a test alive.
92
+ engineWatchers = new Map();
93
+ // The pids whose death this process already watches through a live child
94
+ // handle. A summary re-link that adopts this DSH's own launch record must
95
+ // not stack a poll watcher on top of the handle's close event.
96
+ handledPids = new Set();
97
+ // The route-registered hook for engine process deaths (a crash, a pkill, or
98
+ // a poll-detected death of an adopted process). The observer persists the
99
+ // provider entry's cleared state; a headless profile registers none and the
100
+ // service still settles its own live state.
101
+ deathObserver;
75
102
  constructor(ctx) {
76
103
  super(ctx, 'localInfer');
77
104
  }
@@ -267,47 +294,65 @@ export class LocalInfer extends Service {
267
294
  }
268
295
  /**
269
296
  * providerStatuses resolves the running-state the card paints each saved
270
- * provider's status dot from. It collects every registered adapter's launch
271
- * signature, probes the running process table against those markers, and
272
- * ties a running engine's port to the model the plugin launched it with (the
273
- * launched-models map), so a plugin-launched engine reads as that model and
274
- * a foreign engine reads as "engine running, model unknown". The GPU-usage
275
- * reading feeds the busy-card rule. The answer is the core resolver's: a
276
- * busy card or no engine running reads as unsure for every provider, an
277
- * engine the detector saw but could not tie to a model reads as unsure for
278
- * its configs and stopped for the rest, and a confirmed engine-plus-model
279
- * reads as running. The options inject the probe results, so a test prices a
280
- * busy card or a foreign engine without a live nvidia-smi or ps.
297
+ * provider's status dot from. It prices each provider's own launch signature
298
+ * through the registered adapter (the build mode's binary, the port the
299
+ * provider's config binds, the env keys), so an engine the plugin launched
300
+ * on a non-default port matches its config instead of reading forever as a
301
+ * process no signature covers, and ties the matched port to the model the
302
+ * plugin launched it with (the launched-models map). The GPU-usage reading
303
+ * feeds the busy-card rule. The answer is the core resolver's: a busy card
304
+ * or no engine running reads as unsure for every provider, an engine the
305
+ * detector saw but could not tie to a model reads as unsure for its configs
306
+ * and stopped for the rest, and a confirmed engine-plus-model reads as
307
+ * running. The options inject the probe results, so a test prices a busy
308
+ * card or a foreign engine without a live nvidia-smi or ps.
281
309
  */
282
310
  async providerStatuses(providers, options) {
283
- const refs = [];
284
- for (const adapter of this.engineRegistry.values()) {
285
- const signature = adapter.launchSignature?.();
286
- if (signature !== undefined) {
287
- refs.push({ engineId: adapter.id, signature });
311
+ const signatures = providers.map((provider) => {
312
+ const adapter = this.engineRegistry.get(provider.engine);
313
+ return adapter?.launchSignature?.(provider.config ?? {});
314
+ });
315
+ const markers = [];
316
+ for (const signature of signatures) {
317
+ if (signature !== undefined && !markers.includes(signature.binary)) {
318
+ markers.push(signature.binary);
288
319
  }
289
320
  }
290
- const markers = refs.map((ref) => ref.signature.binary);
291
321
  const processes = options?.processes !== undefined ? options.processes : await probeProcesses(markers);
292
322
  const running = [];
293
- for (const ref of refs) {
294
- const match = processes.find((line) => matchLaunchSignature(line, ref.signature));
323
+ const pricedSignatures = new Set();
324
+ for (const [index, provider] of providers.entries()) {
325
+ const signature = signatures[index];
326
+ if (signature === undefined)
327
+ continue;
328
+ // Two providers sharing one (binary, port) signature price one probe:
329
+ // the first provider's running entry answers for both.
330
+ const signatureKey = `${signature.binary}|${signature.port ?? ''}`;
331
+ if (pricedSignatures.has(signatureKey))
332
+ continue;
333
+ pricedSignatures.add(signatureKey);
334
+ const match = processes.find((line) => matchLaunchSignature(line, signature));
295
335
  if (match === undefined)
296
336
  continue;
297
- const entry = {
298
- engineId: ref.engineId,
299
- ...(ref.signature.port !== undefined ? { port: ref.signature.port } : {}),
300
- };
301
- if (ref.signature.port !== undefined) {
302
- const launched = this.launchedModels.get(ref.signature.port);
303
- if (launched !== undefined && launched.engineId === ref.engineId) {
337
+ const entry = { engineId: provider.engine };
338
+ if (signature.port !== undefined) {
339
+ entry.port = signature.port;
340
+ const launched = this.launchedModels.get(signature.port);
341
+ if (launched !== undefined && launched.engineId === provider.engine) {
304
342
  entry.modelId = launched.modelId;
305
343
  }
306
344
  }
307
345
  running.push(entry);
308
346
  }
309
347
  const gpuUsage = options?.gpuUsage !== undefined ? options.gpuUsage : await probeGpuUsage();
310
- return computeProviderStatuses({ providers, running, gpuBusy: isGpuBusy(gpuUsage) });
348
+ // The provider's resolved port rides into the resolver so two configs of
349
+ // one engine on different ports confirm only against the engine running
350
+ // on their own port.
351
+ const priced = providers.map((provider, index) => {
352
+ const port = signatures[index]?.port;
353
+ return port !== undefined ? { ...provider, port } : provider;
354
+ });
355
+ return computeProviderStatuses({ providers: priced, running, gpuBusy: isGpuBusy(gpuUsage) });
311
356
  }
312
357
  /**
313
358
  * unsureBannerKillCommands returns the bash kill commands for the engine
@@ -411,13 +456,34 @@ export class LocalInfer extends Service {
411
456
  throw new LocalInferError(`no engine adapter is registered for "${plan.engineId}"`, 'LOCAL_INFER_INVALID_ENGINE');
412
457
  }
413
458
  const platform = this.platformConfigRegistry.get(plan.engineId);
414
- const deployResult = await adapter.deploy(plan, platform);
459
+ // The deploy step reports its download and build sub-steps through the
460
+ // progress sink, so the card's button reads the phase that is running.
461
+ const onProgress = options?.onProgress;
462
+ // The trust gate and the auto-rebuild flag ride into the deploy step,
463
+ // which reads them to decide whether a rebuild's missing-binary symlink
464
+ // runs (gate on) or is written to the card's Logs section (off), and
465
+ // whether the git sync (fetch, rebase, rebuild) runs at all.
466
+ // exactOptionalPropertyTypes: only pass the option object when a key is
467
+ // present.
468
+ const hasDeployOptions = options?.allowRunDeps !== undefined || options?.autoRebuild !== undefined;
469
+ const deployOptions = hasDeployOptions
470
+ ? {
471
+ ...(options?.allowRunDeps !== undefined ? { allowRunDeps: options.allowRunDeps } : {}),
472
+ ...(options?.autoRebuild !== undefined ? { autoRebuild: options.autoRebuild } : {}),
473
+ }
474
+ : undefined;
475
+ const deployResult = await adapter.deploy(plan, platform, onProgress, deployOptions);
415
476
  if (!deployResult.ok) {
416
477
  // exactOptionalPropertyTypes: only write the detail key when it is present.
417
478
  const result = { plan, healthy: false };
418
479
  if (deployResult.detail !== undefined) {
419
480
  result.detail = deployResult.detail;
420
481
  }
482
+ // A failed deploy still carries the build decision and output it captured,
483
+ // so the card's Logs section shows the step that broke.
484
+ if (deployResult.logs !== undefined && deployResult.logs !== '') {
485
+ result.logs = deployResult.logs;
486
+ }
421
487
  return result;
422
488
  }
423
489
  // A successful deploy means the engine process is running, so the
@@ -425,12 +491,31 @@ export class LocalInfer extends Service {
425
491
  // already priced by the estimator, and pricing the GPU's used VRAM on
426
492
  // top would double-count.
427
493
  this.launchedEngines.add(plan.engineId);
494
+ if (plan.port !== undefined) {
495
+ // A fresh launch on a port replaces any adopted-process watcher left
496
+ // over from the engine this launch displaced on that port.
497
+ this.stopEngineWatcher(plan.port);
498
+ const ports = this.launchedPorts.get(plan.engineId) ?? new Set();
499
+ ports.add(plan.port);
500
+ this.launchedPorts.set(plan.engineId, ports);
501
+ }
428
502
  // The provider-status detector ties a running engine's port to the model
429
503
  // the plugin launched it with, so a live engine the plugin started reads
430
504
  // as that model (green) rather than "engine running, model unknown".
431
505
  if (plan.modelId !== undefined && plan.port !== undefined) {
432
506
  this.launchedModels.set(plan.port, { engineId: plan.engineId, modelId: plan.modelId });
433
507
  }
508
+ // The exit watch attaches before the health poll, so an engine that
509
+ // crashes mid-startup (or is pkill'd during the poll) settles the live
510
+ // state and reaches the death observer instead of leaving the port tied
511
+ // to a model that is no longer serving.
512
+ if (deployResult.handle !== undefined) {
513
+ this.watchLaunchedHandle(deployResult.handle, plan);
514
+ }
515
+ // The health poll is the launch phase: the rebuild (download, build) is
516
+ // done and the engine process is up, so the card's button reads
517
+ // "Launching..." while it waits for the engine to report ready.
518
+ onProgress?.('launch');
434
519
  const health = await adapter.healthCheck(plan, options);
435
520
  const launchResult = { plan, healthy: health.healthy };
436
521
  if (health.detail !== undefined) {
@@ -449,10 +534,16 @@ export class LocalInfer extends Service {
449
534
  }
450
535
  }
451
536
  // Read the engine's captured output after the health poll, so the result
452
- // carries the log lines the engine printed while it came up. The route
453
- // persists these as the last run and writes them to the configured file.
454
- const handleLogs = deployResult.handle?.logs;
455
- if (handleLogs !== undefined && handleLogs !== '') {
537
+ // carries the log lines the engine printed while it came up. The deploy
538
+ // step's own captured output (the rebuild decision, the build command, and
539
+ // its output) is kept separate in buildLogs, so the route orders the launch
540
+ // command after a build (or a direct launch) and before the engine output.
541
+ const buildLogs = deployResult.logs ?? '';
542
+ const handleLogs = deployResult.handle?.logs ?? '';
543
+ if (buildLogs !== '') {
544
+ launchResult.buildLogs = buildLogs;
545
+ }
546
+ if (handleLogs !== '') {
456
547
  launchResult.logs = handleLogs;
457
548
  }
458
549
  return launchResult;
@@ -489,16 +580,138 @@ export class LocalInfer extends Service {
489
580
  // resolver keeps its 3 GiB default and the provider-status detector
490
581
  // ties its port to the model the record names.
491
582
  this.launchedEngines.add(entry.record.engineId);
492
- if (entry.record.port !== undefined && entry.record.modelId !== undefined) {
493
- this.launchedModels.set(entry.record.port, {
494
- engineId: entry.record.engineId,
495
- modelId: entry.record.modelId,
496
- });
583
+ if (entry.record.port !== undefined) {
584
+ const ports = this.launchedPorts.get(entry.record.engineId) ?? new Set();
585
+ ports.add(entry.record.port);
586
+ this.launchedPorts.set(entry.record.engineId, ports);
587
+ if (entry.record.modelId !== undefined) {
588
+ this.launchedModels.set(entry.record.port, {
589
+ engineId: entry.record.engineId,
590
+ modelId: entry.record.modelId,
591
+ });
592
+ }
593
+ // Re-attach the exit watch the restart dropped: the adopted child
594
+ // has no ChildProcess to listen on, so the poll stands in and the
595
+ // death settles the state the same way a live child's close event
596
+ // does. A repeat summary call keeps the existing watcher.
597
+ this.watchAdoptedEngine(entry.record, processAlive);
497
598
  }
498
599
  }
499
600
  }
500
601
  return outcomes;
501
602
  }
603
+ /**
604
+ * setEngineDeathObserver registers the route's hook for engine process
605
+ * deaths: a crash or pkill seen through a launched handle's exit event, or a
606
+ * poll-detected death of a process adopted from a stored launch record. The
607
+ * observer persists the provider entry's cleared state so the card reads the
608
+ * provider stopped instead of carrying a stale record. Passing undefined
609
+ * clears it, which the route's effect does on dispose.
610
+ */
611
+ setEngineDeathObserver(observer) {
612
+ this.deathObserver = observer;
613
+ }
614
+ /**
615
+ * watchLaunchedHandle ties a launched child's one-shot exit to the live
616
+ * state: the host's close/error event (a crash, a pkill, or the stop kill)
617
+ * reports through observeEngineDeath. A handle whose host cannot watch (no
618
+ * onExit, a stub) is left to the re-link rule to price after a restart.
619
+ */
620
+ watchLaunchedHandle(handle, plan) {
621
+ if (typeof handle.onExit !== 'function')
622
+ return;
623
+ if (handle.pid > 0) {
624
+ this.handledPids.add(handle.pid);
625
+ }
626
+ handle.onExit((exit) => {
627
+ this.handledPids.delete(handle.pid);
628
+ this.observeEngineDeath({
629
+ engineId: plan.engineId,
630
+ ...(plan.port !== undefined ? { port: plan.port } : {}),
631
+ ...(plan.modelId !== undefined ? { modelId: plan.modelId } : {}),
632
+ ...(handle.pid > 0 ? { pid: handle.pid } : {}),
633
+ code: exit.code,
634
+ signal: exit.signal,
635
+ ...(exit.error !== undefined ? { detail: exit.error } : {}),
636
+ });
637
+ });
638
+ }
639
+ /**
640
+ * watchAdoptedEngine re-attaches the exit watch for a process this DSH did
641
+ * not spawn: a surviving engine adopted from a stored launch record has no
642
+ * ChildProcess to listen on after a restart, so an unref'd poll on the
643
+ * liveness probe stands in for the exit event. When the pid dies the watcher
644
+ * stops itself and reports through observeEngineDeath, so the provider's
645
+ * state settles without waiting for the next summary re-link to price the
646
+ * record dead.
647
+ */
648
+ watchAdoptedEngine(record, processAlive) {
649
+ if (record.port === undefined)
650
+ return;
651
+ if (this.engineWatchers.has(record.port))
652
+ return;
653
+ // A launch this DSH made itself already has the child handle's close
654
+ // event; a poll watcher would only double-report the same death.
655
+ if (this.handledPids.has(record.pid))
656
+ return;
657
+ const port = record.port;
658
+ const timer = setInterval(() => {
659
+ if (processAlive(record.pid))
660
+ return;
661
+ this.observeEngineDeath({
662
+ engineId: record.engineId,
663
+ port,
664
+ ...(record.modelId !== undefined ? { modelId: record.modelId } : {}),
665
+ pid: record.pid,
666
+ code: null,
667
+ signal: null,
668
+ detail: 'the adopted engine process exited outside this DSH session',
669
+ });
670
+ }, PROCESS_WATCH_MS);
671
+ timer.unref();
672
+ this.engineWatchers.set(port, { pid: record.pid, timer });
673
+ }
674
+ /**
675
+ * stopEngineWatcher cancels one port's adopted-process poll, if it has one.
676
+ */
677
+ stopEngineWatcher(port) {
678
+ const watcher = this.engineWatchers.get(port);
679
+ if (watcher === undefined)
680
+ return;
681
+ clearInterval(watcher.timer);
682
+ this.engineWatchers.delete(port);
683
+ }
684
+ /**
685
+ * observeEngineDeath settles one engine process's death against the live
686
+ * state: the port-to-model tie drops when the dead engine still owns the
687
+ * port, the port leaves the engine's live ledger, the plugin-launched flag
688
+ * clears with the engine's last live port, the adopted-process watcher for
689
+ * the port stops, and the registered death observer (the route's persistence
690
+ * hook) receives the event. The launch record itself is the route's: the
691
+ * observer clears the provider entry's record so the card reads the provider
692
+ * stopped, not stale.
693
+ */
694
+ observeEngineDeath(event) {
695
+ if (event.port !== undefined) {
696
+ const tracked = this.launchedModels.get(event.port);
697
+ if (tracked !== undefined && tracked.engineId === event.engineId) {
698
+ this.launchedModels.delete(event.port);
699
+ }
700
+ this.stopEngineWatcher(event.port);
701
+ const ports = this.launchedPorts.get(event.engineId);
702
+ if (ports !== undefined) {
703
+ ports.delete(event.port);
704
+ if (ports.size === 0) {
705
+ this.launchedPorts.delete(event.engineId);
706
+ }
707
+ }
708
+ }
709
+ const remaining = event.port !== undefined ? this.launchedPorts.get(event.engineId) : undefined;
710
+ if (remaining === undefined || remaining.size === 0) {
711
+ this.launchedEngines.delete(event.engineId);
712
+ }
713
+ this.deathObserver?.(event);
714
+ }
502
715
  /**
503
716
  * stop signals the engine a launch started: it resolves the adapter by the
504
717
  * plan's engineId and runs the adapter's stop step (kill the tracked process,
@@ -510,14 +723,31 @@ export class LocalInfer extends Service {
510
723
  throw new LocalInferError(`no engine adapter is registered for "${plan.engineId}"`, 'LOCAL_INFER_INVALID_ENGINE');
511
724
  }
512
725
  await adapter.stop(plan);
513
- // The stop step signalled the tracked process or container, so the
514
- // engine is no longer plugin-launched and the OS-reserve resolver may
515
- // price the card's live usage for it again.
516
- this.launchedEngines.delete(plan.engineId);
517
726
  // Clear the port-to-model entry too, so the provider-status detector no
518
- // longer ties this port to the model the plugin launched it with.
727
+ // longer ties this port to the model the plugin launched it with, and
728
+ // cancel any adopted-process watcher standing on the port.
519
729
  if (plan.port !== undefined) {
520
730
  this.launchedModels.delete(plan.port);
731
+ this.stopEngineWatcher(plan.port);
732
+ const ports = this.launchedPorts.get(plan.engineId);
733
+ if (ports !== undefined) {
734
+ ports.delete(plan.port);
735
+ if (ports.size === 0) {
736
+ this.launchedPorts.delete(plan.engineId);
737
+ }
738
+ }
739
+ // The engine is no longer plugin-launched only when it has no other
740
+ // live port: a second provider's engine on another port keeps the
741
+ // OS-reserve default.
742
+ const remaining = this.launchedPorts.get(plan.engineId);
743
+ if (remaining === undefined || remaining.size === 0) {
744
+ this.launchedEngines.delete(plan.engineId);
745
+ }
746
+ }
747
+ else {
748
+ // A plan with no port (a compose profile the stop step signalled down)
749
+ // clears the flag outright.
750
+ this.launchedEngines.delete(plan.engineId);
521
751
  }
522
752
  }
523
753
  /**
@@ -1025,6 +1255,12 @@ const shellRunCommand = (command) => new Promise(resolve => {
1025
1255
  };
1026
1256
  child.stdout.on('data', append);
1027
1257
  child.stderr.on('data', append);
1258
+ // Without a listener, a child that fails to start (a missing shell) emits
1259
+ // an unlistened 'error' and throws it as an uncaughtException through the
1260
+ // host. Read it as a failed command instead.
1261
+ child.on('error', (error) => {
1262
+ resolve({ ok: false, detail: error.message });
1263
+ });
1028
1264
  child.on('close', code => {
1029
1265
  if (code === 0) {
1030
1266
  resolve({ ok: true });
@@ -1,4 +1,4 @@
1
- import type { CommandOutcome, Config, DefaultModel, ModelDirectoryEntry, EngineAdapter, EngineDepPlan, EnginePlatformConfig, Guideline, GuidelineResult, GpuUsageProbe, HardwareProfile, InstallResult, LaunchPlan, ModelRow, Preset, PresetSource, ProcessLine, ProviderLaunchRecord, ProviderRef, ProviderStatus, ReLinkOutcome, RegisterResult, ReserveResolution, ReserveSource, ResolvedDefaults, SettingsOp, SystemDep, VramBreakdown, VramModel, VramVerdict } from '@dsh/dsh-plugin-local-infer/core';
1
+ import type { CommandOutcome, Config, DefaultModel, ModelDirectoryEntry, EngineAdapter, EngineDepPlan, EnginePlatformConfig, Guideline, GuidelineResult, GpuUsageProbe, HardwareProfile, InstallResult, LaunchPlan, LaunchProgress, ModelRow, Preset, PresetSource, ProcessLine, ProviderLaunchRecord, ProviderRef, ProviderStatus, ReLinkOutcome, RegisterResult, ReserveResolution, ReserveSource, ResolvedDefaults, SettingsOp, SystemDep, VramBreakdown, VramModel, VramVerdict } from 'dsh-plugin-local-infer/core';
2
2
  /**
3
3
  * EstimateResult is the answer to an estimate call: the full VRAM breakdown,
4
4
  * the one-word verdict the meter renders, the source the numbers came from,
@@ -37,6 +37,24 @@ export interface ProviderStatusOptions {
37
37
  processes?: ProcessLine[];
38
38
  gpuUsage?: GpuUsageProbe | null;
39
39
  }
40
+ /**
41
+ * EngineDeathEvent is the one-shot report that an engine process the plugin
42
+ * tracked has ended: an observed crash or pkill (the exit code and signal from
43
+ * a live child's close event, or a spawn error detail for a child that never
44
+ * ran), or a poll-detected death of a process adopted from a stored launch
45
+ * record after a DSH restart (a null code and signal with a detail). The route
46
+ * registers the observer that persists the provider entry's cleared state, so
47
+ * the card reads the provider stopped instead of carrying a stale record.
48
+ */
49
+ export interface EngineDeathEvent {
50
+ engineId: string;
51
+ port?: number;
52
+ modelId?: string;
53
+ pid?: number;
54
+ code: number | null;
55
+ signal: string | null;
56
+ detail?: string;
57
+ }
40
58
  /**
41
59
  * LaunchResult is the answer to a launch call: the plan that was started,
42
60
  * whether the health check passed, a detail for the UI, and the engine's
@@ -47,13 +65,18 @@ export interface ProviderStatusOptions {
47
65
  * deploy pulls an image only), and `parentPid` is the DSH process pid that
48
66
  * launched the engine (absent when the host process did not identify as
49
67
  * DSH), the two fields the route persists as the provider entry's launch
50
- * record for the restart re-link.
68
+ * record for the restart re-link. `buildLogs` is the deploy step's own
69
+ * captured output (the rebuild decision, the build command, and its output)
70
+ * kept separate from the engine's runtime `logs`, so the route can order the
71
+ * launch command after a build (or after a direct launch) and before the
72
+ * engine output.
51
73
  */
52
74
  export interface LaunchResult {
53
75
  plan: LaunchPlan;
54
76
  healthy: boolean;
55
77
  detail?: string;
56
78
  logs?: string;
79
+ buildLogs?: string;
57
80
  pid?: number;
58
81
  parentPid?: number;
59
82
  }
@@ -61,10 +84,22 @@ export interface LaunchResult {
61
84
  * LaunchOptions tunes the launch health poll: the interval between attempts
62
85
  * and the attempt cap. The service passes the config's healthPollMs and
63
86
  * healthMaxAttempts through; an absent option keeps the adapter's default.
87
+ * `onProgress`, when present, is called as each launch phase (the deploy
88
+ * sub-steps and the health poll) begins, so the card's Launch control reads
89
+ * the phase that is actually running. `allowRunDeps` is the Play/Run trust gate
90
+ * (the card's "Allow one-click install (sudo)" checkbox) the deploy step reads
91
+ * to decide whether a rebuild's missing-binary symlink into /usr/local/bin runs
92
+ * (on) or is written to the card's Logs section (off). `autoRebuild` is the
93
+ * card's "Auto Rebuild" checkbox: when false, the deploy step skips the git
94
+ * sync (fetch, rebase, rebuild) and launches the checkout as-is. Absent reads
95
+ * as true (the rebuild runs).
64
96
  */
65
97
  export interface LaunchOptions {
66
98
  pollMs?: number;
67
99
  maxAttempts?: number;
100
+ onProgress?: LaunchProgress;
101
+ allowRunDeps?: boolean;
102
+ autoRebuild?: boolean;
68
103
  }
69
104
  /**
70
105
  * RegisterModelOptions tunes the model registration: agentDefault points the
@@ -216,6 +251,13 @@ export interface LocalInferService {
216
251
  processAlive?: (pid: number) => boolean;
217
252
  commandLine?: (pid: number) => string | undefined;
218
253
  }): Promise<Record<string, ReLinkOutcome>>;
254
+ /**
255
+ * setEngineDeathObserver registers the callback the route uses to persist a
256
+ * provider entry's cleared state when its engine process dies (a crash, a
257
+ * pkill, or a poll-detected death of an adopted process). Passing undefined
258
+ * clears it, which the route's effect does on dispose.
259
+ */
260
+ setEngineDeathObserver(observer: ((event: EngineDeathEvent) => void) | undefined): void;
219
261
  registerModel(plan: LaunchPlan, model: ModelRow, options?: RegisterModelOptions): Promise<RegisterResult>;
220
262
  removeModel(providerId: string, modelId: string): Promise<void>;
221
263
  resolveDefaults(engineId: string, modelId: string, hw: HardwareProfile, explicit?: Config): Promise<ResolvedDefaults>;
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@dsh/dsh-plugin-local-infer-service",
2
+ "name": "dsh-plugin-local-infer-service",
3
3
  "version": "0.1.0",
4
4
  "description": "Service Definition for the dsh local-inference capability seam (ctx.localInfer): the engine, preset, and guideline registries plus the orchestration entry points (estimate, guidelines, deps, installString, runInstall, launch, registerModel). Carries no store of its own.",
5
5
  "keywords": [
@@ -30,7 +30,7 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@dsh/dsh-plugin-local-infer-core": "workspace:^"
33
+ "dsh-plugin-local-infer-core": "workspace:^"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": ">=4.0.1"
@@ -8,7 +8,7 @@
8
8
  * In the scaffold this plugin is a no-op placeholder that injects the seam so
9
9
  * the bundle loads with zero tools. Phase 8 registers the four tools and the
10
10
  * prompt section.
11
- * @module @dsh/dsh-plugin-local-infer/tool
11
+ * @module dsh-plugin-local-infer/tool
12
12
  */
13
13
  import type { Context } from '@deepseek-ai/cordis';
14
14
  export declare const name = "local-infer-tool";
@@ -8,7 +8,7 @@
8
8
  * In the scaffold this plugin is a no-op placeholder that injects the seam so
9
9
  * the bundle loads with zero tools. Phase 8 registers the four tools and the
10
10
  * prompt section.
11
- * @module @dsh/dsh-plugin-local-infer/tool
11
+ * @module dsh-plugin-local-infer/tool
12
12
  */
13
13
  export const name = 'local-infer-tool';
14
14
  export const inject = ['localInfer'];
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@dsh/dsh-plugin-local-infer-tool",
2
+ "name": "dsh-plugin-local-infer-tool",
3
3
  "version": "0.1.0",
4
4
  "description": "Model-facing tools for the dsh local-inference plugin: local_infer_estimate, local_infer_launch, local_infer_status, and local_infer_register, each on its own timeout budget, plus the system-prompt section that tells the agent when to use them",
5
5
  "keywords": [
@@ -29,8 +29,8 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@dsh/dsh-plugin-local-infer-core": "workspace:^",
33
- "@dsh/dsh-plugin-local-infer-service": "workspace:^"
32
+ "dsh-plugin-local-infer-core": "workspace:^",
33
+ "dsh-plugin-local-infer-service": "workspace:^"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@deepseek-ai/cordis": ">=4.0.1"
@@ -5,9 +5,9 @@
5
5
  * "Get" gate the plan names (disabled until every dep reports satisfied).
6
6
  * The model is pure data the card draws, so the mapping stays unit-testable
7
7
  * here.
8
- * @module @dsh/dsh-plugin-local-infer/ui/deps
8
+ * @module dsh-plugin-local-infer/ui/deps
9
9
  */
10
- import type { DepCheck, DepStatus } from '@dsh/dsh-plugin-local-infer/core';
10
+ import type { DepCheck, DepStatus } from 'dsh-plugin-local-infer/core';
11
11
  /**
12
12
  * DepItemModel is one row of the panel's dep list: the dep id, the purpose
13
13
  * the user reads, the current status, and whether installing it needs sudo.
@@ -5,7 +5,7 @@
5
5
  * "Get" gate the plan names (disabled until every dep reports satisfied).
6
6
  * The model is pure data the card draws, so the mapping stays unit-testable
7
7
  * here.
8
- * @module @dsh/dsh-plugin-local-infer/ui/deps
8
+ * @module dsh-plugin-local-infer/ui/deps
9
9
  */
10
10
  /**
11
11
  * renderDeps maps the checkDeps results and the rendered install string to
@@ -3,9 +3,9 @@
3
3
  * `group`, and a field per `ParameterSpec`. The model is pure data, so the
4
4
  * panel (Phase 8) renders it without touching the domain model, and the
5
5
  * mapping from `form` kind to control kind stays unit-testable here.
6
- * @module @dsh/dsh-plugin-local-infer/ui/form
6
+ * @module dsh-plugin-local-infer/ui/form
7
7
  */
8
- import type { ComboOption, Config, EnumOption, HardwareProfile, ParameterForm, ParameterSpec } from '@dsh/dsh-plugin-local-infer/core';
8
+ import type { ComboOption, Config, EnumOption, HardwareProfile, ParameterForm, ParameterSpec } from 'dsh-plugin-local-infer/core';
9
9
  /**
10
10
  * The control kinds the panel switches on: checkbox, dropdown, slider plus
11
11
  * number input, text, a directory/file picker, and a dropdown plus free-text
@@ -3,7 +3,7 @@
3
3
  * `group`, and a field per `ParameterSpec`. The model is pure data, so the
4
4
  * panel (Phase 8) renders it without touching the domain model, and the
5
5
  * mapping from `form` kind to control kind stays unit-testable here.
6
- * @module @dsh/dsh-plugin-local-infer/ui/form
6
+ * @module dsh-plugin-local-infer/ui/form
7
7
  */
8
8
  /**
9
9
  * The group label a spec without a `group` renders under.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * The render models (form, meter, warnings) are pure data the panel draws
9
9
  * from; Phase 8 wires them into one panel with the lifecycle controls.
10
- * @module @dsh/dsh-plugin-local-infer/ui
10
+ * @module dsh-plugin-local-infer/ui
11
11
  */
12
12
  import type { Context } from '@deepseek-ai/cordis';
13
13
  export declare const name = "local-infer-ui";
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * The render models (form, meter, warnings) are pure data the panel draws
9
9
  * from; Phase 8 wires them into one panel with the lifecycle controls.
10
- * @module @dsh/dsh-plugin-local-infer/ui
10
+ * @module dsh-plugin-local-infer/ui
11
11
  */
12
12
  import { registerLocalInferRoute } from "./route.js";
13
13
  export const name = 'local-infer-ui';
@@ -6,9 +6,9 @@
6
6
  * as authoritative. The model is pure data, so the panel (Phase 8) renders
7
7
  * it without touching the domain model, and the mapping stays unit-testable
8
8
  * here.
9
- * @module @dsh/dsh-plugin-local-infer/ui/meter
9
+ * @module dsh-plugin-local-infer/ui/meter
10
10
  */
11
- import type { ReserveSource, VramBreakdown, VramVerdict } from '@dsh/dsh-plugin-local-infer/core';
11
+ import type { ReserveSource, VramBreakdown, VramVerdict } from 'dsh-plugin-local-infer/core';
12
12
  /**
13
13
  * MeterSegment is one segment of the to-scale bar. `percent` is the share of
14
14
  * the full card the segment occupies, so the bar reads against the card the