dsh-plugin-local-infer 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +22 -4
  2. package/cordis.patch.yml +7 -0
  3. package/docs/zh/README.md +14 -2
  4. package/dsh/client.js +4195 -4522
  5. package/package.json +28 -11
  6. package/packages/core/lib/adapter.d.ts +133 -9
  7. package/packages/core/lib/adapter.js +74 -1
  8. package/packages/core/lib/config.d.ts +1 -1
  9. package/packages/core/lib/config.js +1 -1
  10. package/packages/core/lib/debug.d.ts +1 -1
  11. package/packages/core/lib/debug.js +1 -1
  12. package/packages/core/lib/defaults.d.ts +1 -1
  13. package/packages/core/lib/defaults.js +1 -1
  14. package/packages/core/lib/deps.d.ts +44 -17
  15. package/packages/core/lib/deps.js +24 -8
  16. package/packages/core/lib/estimator.d.ts +1 -1
  17. package/packages/core/lib/estimator.js +1 -1
  18. package/packages/core/lib/guideline.d.ts +1 -1
  19. package/packages/core/lib/guideline.js +1 -1
  20. package/packages/core/lib/hardware.d.ts +1 -1
  21. package/packages/core/lib/hardware.js +1 -1
  22. package/packages/core/lib/hf-cache.d.ts +70 -0
  23. package/packages/core/lib/hf-cache.js +83 -0
  24. package/packages/core/lib/index.d.ts +16 -9
  25. package/packages/core/lib/index.js +10 -6
  26. package/packages/core/lib/infer-cache.d.ts +39 -0
  27. package/packages/core/lib/infer-cache.js +53 -0
  28. package/packages/core/lib/launch-command.d.ts +8 -4
  29. package/packages/core/lib/launch-command.js +13 -6
  30. package/packages/core/lib/launch-record.d.ts +1 -1
  31. package/packages/core/lib/launch-record.js +1 -1
  32. package/packages/core/lib/logs.d.ts +8 -1
  33. package/packages/core/lib/logs.js +12 -4
  34. package/packages/core/lib/models.d.ts +120 -14
  35. package/packages/core/lib/models.js +209 -17
  36. package/packages/core/lib/parameter.d.ts +31 -3
  37. package/packages/core/lib/parameter.js +1 -1
  38. package/packages/core/lib/progress.d.ts +54 -0
  39. package/packages/core/lib/progress.js +120 -0
  40. package/packages/core/lib/registrar.d.ts +20 -9
  41. package/packages/core/lib/registrar.js +27 -7
  42. package/packages/core/lib/reserve.d.ts +11 -3
  43. package/packages/core/lib/reserve.js +6 -4
  44. package/packages/core/lib/status.d.ts +12 -6
  45. package/packages/core/lib/status.js +21 -10
  46. package/packages/core/lib/sudo.d.ts +80 -0
  47. package/packages/core/lib/sudo.js +78 -0
  48. package/packages/core/package.json +1 -1
  49. package/packages/orchestrator/lib/config.d.ts +63 -0
  50. package/packages/orchestrator/lib/config.js +111 -0
  51. package/packages/orchestrator/lib/index.d.ts +51 -0
  52. package/packages/orchestrator/lib/index.js +73 -0
  53. package/packages/orchestrator/lib/route.d.ts +60 -0
  54. package/packages/orchestrator/lib/route.js +159 -0
  55. package/packages/orchestrator/locale/en.json +6 -0
  56. package/packages/orchestrator/package.json +39 -0
  57. package/packages/orchestrator/prompt.md +87 -0
  58. package/packages/providers/lib/acquire.d.ts +92 -14
  59. package/packages/providers/lib/acquire.js +215 -37
  60. package/packages/providers/lib/adapters.d.ts +136 -12
  61. package/packages/providers/lib/adapters.js +597 -70
  62. package/packages/providers/lib/debug.d.ts +1 -1
  63. package/packages/providers/lib/debug.js +1 -1
  64. package/packages/providers/lib/deps.d.ts +27 -230
  65. package/packages/providers/lib/deps.js +61 -214
  66. package/packages/providers/lib/engines/basalt/basalt.d.ts +130 -0
  67. package/packages/providers/lib/engines/basalt/basalt.js +304 -0
  68. package/packages/providers/lib/engines/basalt/basaltAdapter.d.ts +85 -0
  69. package/packages/providers/lib/engines/basalt/basaltAdapter.js +439 -0
  70. package/packages/providers/lib/engines/basalt/basaltLinux.d.ts +23 -0
  71. package/packages/providers/lib/engines/basalt/basaltLinux.js +47 -0
  72. package/packages/providers/lib/engines/basalt/basaltMacOS.d.ts +17 -0
  73. package/packages/providers/lib/engines/basalt/basaltMacOS.js +19 -0
  74. package/packages/providers/lib/engines/basalt/basaltWindows.d.ts +24 -0
  75. package/packages/providers/lib/engines/basalt/basaltWindows.js +49 -0
  76. package/packages/providers/lib/engines/engineOs.d.ts +24 -0
  77. package/packages/providers/lib/engines/engineOs.js +17 -0
  78. package/packages/providers/lib/engines/{hyperqwen.d.ts → hyperqwen/hyperqwen.d.ts} +2 -2
  79. package/packages/providers/lib/engines/{hyperqwen.js → hyperqwen/hyperqwen.js} +14 -2
  80. package/packages/providers/lib/engines/{hyperqwenAdapter.d.ts → hyperqwen/hyperqwenAdapter.d.ts} +6 -6
  81. package/packages/providers/lib/engines/{hyperqwenAdapter.js → hyperqwen/hyperqwenAdapter.js} +24 -14
  82. package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.d.ts +9 -0
  83. package/packages/providers/lib/engines/hyperqwen/hyperqwenArch.js +9 -0
  84. package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.d.ts +9 -0
  85. package/packages/providers/lib/engines/hyperqwen/hyperqwenDebian.js +9 -0
  86. package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.d.ts +9 -0
  87. package/packages/providers/lib/engines/hyperqwen/hyperqwenFedora.js +9 -0
  88. package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.d.ts +10 -0
  89. package/packages/providers/lib/engines/hyperqwen/hyperqwenLinux.js +14 -0
  90. package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.d.ts +9 -0
  91. package/packages/providers/lib/engines/hyperqwen/hyperqwenMacOS.js +9 -0
  92. package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.d.ts +9 -0
  93. package/packages/providers/lib/engines/hyperqwen/hyperqwenWindows.js +9 -0
  94. package/packages/providers/lib/engines/{infernix.d.ts → infernix/infernix.d.ts} +7 -4
  95. package/packages/providers/lib/engines/{infernix.js → infernix/infernix.js} +52 -7
  96. package/packages/providers/lib/engines/{infernixAdapter.d.ts → infernix/infernixAdapter.d.ts} +5 -6
  97. package/packages/providers/lib/engines/{infernixAdapter.js → infernix/infernixAdapter.js} +75 -42
  98. package/packages/providers/lib/engines/infernix/infernixArch.d.ts +9 -0
  99. package/packages/providers/lib/engines/infernix/infernixArch.js +9 -0
  100. package/packages/providers/lib/engines/infernix/infernixDebian.d.ts +9 -0
  101. package/packages/providers/lib/engines/infernix/infernixDebian.js +9 -0
  102. package/packages/providers/lib/engines/infernix/infernixFedora.d.ts +9 -0
  103. package/packages/providers/lib/engines/infernix/infernixFedora.js +9 -0
  104. package/packages/providers/lib/engines/infernix/infernixLinux.d.ts +17 -0
  105. package/packages/providers/lib/engines/infernix/infernixLinux.js +35 -0
  106. package/packages/providers/lib/engines/infernix/infernixMacOS.d.ts +16 -0
  107. package/packages/providers/lib/engines/infernix/infernixMacOS.js +18 -0
  108. package/packages/providers/lib/engines/infernix/infernixWindows.d.ts +16 -0
  109. package/packages/providers/lib/engines/infernix/infernixWindows.js +34 -0
  110. package/packages/providers/lib/engines/{llamacpp.d.ts → llamacpp/llamacpp.d.ts} +2 -2
  111. package/packages/providers/lib/engines/{llamacpp.js → llamacpp/llamacpp.js} +12 -2
  112. package/packages/providers/lib/engines/{llamacppAdapter.d.ts → llamacpp/llamacppAdapter.d.ts} +5 -6
  113. package/packages/providers/lib/engines/{llamacppAdapter.js → llamacpp/llamacppAdapter.js} +87 -45
  114. package/packages/providers/lib/engines/llamacpp/llamacppArch.d.ts +9 -0
  115. package/packages/providers/lib/engines/llamacpp/llamacppArch.js +9 -0
  116. package/packages/providers/lib/engines/llamacpp/llamacppDebian.d.ts +9 -0
  117. package/packages/providers/lib/engines/llamacpp/llamacppDebian.js +9 -0
  118. package/packages/providers/lib/engines/llamacpp/llamacppFedora.d.ts +9 -0
  119. package/packages/providers/lib/engines/llamacpp/llamacppFedora.js +9 -0
  120. package/packages/providers/lib/engines/llamacpp/llamacppLinux.d.ts +17 -0
  121. package/packages/providers/lib/engines/llamacpp/llamacppLinux.js +28 -0
  122. package/packages/providers/lib/engines/llamacpp/llamacppMacOS.d.ts +15 -0
  123. package/packages/providers/lib/engines/llamacpp/llamacppMacOS.js +17 -0
  124. package/packages/providers/lib/engines/llamacpp/llamacppWindows.d.ts +15 -0
  125. package/packages/providers/lib/engines/llamacpp/llamacppWindows.js +26 -0
  126. package/packages/providers/lib/engines/{ninfer.d.ts → ninfer/ninfer.d.ts} +2 -2
  127. package/packages/providers/lib/engines/{ninfer.js → ninfer/ninfer.js} +24 -2
  128. package/packages/providers/lib/engines/{ninferAdapter.d.ts → ninfer/ninferAdapter.d.ts} +5 -6
  129. package/packages/providers/lib/engines/{ninferAdapter.js → ninfer/ninferAdapter.js} +123 -55
  130. package/packages/providers/lib/engines/ninfer/ninferArch.d.ts +9 -0
  131. package/packages/providers/lib/engines/ninfer/ninferArch.js +9 -0
  132. package/packages/providers/lib/engines/ninfer/ninferDebian.d.ts +9 -0
  133. package/packages/providers/lib/engines/ninfer/ninferDebian.js +9 -0
  134. package/packages/providers/lib/engines/ninfer/ninferFedora.d.ts +9 -0
  135. package/packages/providers/lib/engines/ninfer/ninferFedora.js +9 -0
  136. package/packages/providers/lib/engines/ninfer/ninferLinux.d.ts +17 -0
  137. package/packages/providers/lib/engines/ninfer/ninferLinux.js +37 -0
  138. package/packages/providers/lib/engines/ninfer/ninferMacOS.d.ts +15 -0
  139. package/packages/providers/lib/engines/ninfer/ninferMacOS.js +17 -0
  140. package/packages/providers/lib/engines/ninfer/ninferWindows.d.ts +29 -0
  141. package/packages/providers/lib/engines/ninfer/ninferWindows.js +59 -0
  142. package/packages/providers/lib/engines/strata/strata.d.ts +1 -0
  143. package/packages/providers/lib/engines/{strata.js → strata/strata.js} +70 -3
  144. package/packages/providers/lib/engines/{strataAdapter.d.ts → strata/strataAdapter.d.ts} +5 -6
  145. package/packages/providers/lib/engines/{strataAdapter.js → strata/strataAdapter.js} +147 -111
  146. package/packages/providers/lib/engines/strata/strataArch.d.ts +9 -0
  147. package/packages/providers/lib/engines/strata/strataArch.js +9 -0
  148. package/packages/providers/lib/engines/strata/strataDebian.d.ts +9 -0
  149. package/packages/providers/lib/engines/strata/strataDebian.js +9 -0
  150. package/packages/providers/lib/engines/strata/strataFedora.d.ts +9 -0
  151. package/packages/providers/lib/engines/strata/strataFedora.js +9 -0
  152. package/packages/providers/lib/engines/strata/strataLinux.d.ts +21 -0
  153. package/packages/providers/lib/engines/strata/strataLinux.js +30 -0
  154. package/packages/providers/lib/engines/strata/strataMacOS.d.ts +16 -0
  155. package/packages/providers/lib/engines/strata/strataMacOS.js +17 -0
  156. package/packages/providers/lib/engines/strata/strataWindows.d.ts +27 -0
  157. package/packages/providers/lib/engines/strata/strataWindows.js +37 -0
  158. package/packages/providers/lib/engines.d.ts +7 -5
  159. package/packages/providers/lib/engines.js +21 -10
  160. package/packages/providers/lib/guidelines.d.ts +5 -4
  161. package/packages/providers/lib/guidelines.js +18 -5
  162. package/packages/providers/lib/index.d.ts +33 -29
  163. package/packages/providers/lib/index.js +45 -33
  164. package/packages/providers/lib/model-defaults.d.ts +5 -1
  165. package/packages/providers/lib/model-defaults.js +25 -1
  166. package/packages/providers/lib/platform.d.ts +8 -5
  167. package/packages/providers/lib/platform.js +23 -9
  168. package/packages/providers/lib/presets.d.ts +6 -4
  169. package/packages/providers/lib/presets.js +53 -5
  170. package/packages/providers/lib/systemDeps.d.ts +313 -0
  171. package/packages/providers/lib/systemDeps.js +284 -0
  172. package/packages/providers/lib/vram.d.ts +34 -8
  173. package/packages/providers/lib/vram.js +81 -8
  174. package/packages/providers/package.json +2 -2
  175. package/packages/service/lib/index.d.ts +176 -21
  176. package/packages/service/lib/index.js +589 -86
  177. package/packages/service/lib/launch-target.d.ts +19 -0
  178. package/packages/service/lib/launch-target.js +28 -0
  179. package/packages/service/lib/run-logs.d.ts +72 -0
  180. package/packages/service/lib/run-logs.js +125 -0
  181. package/packages/service/lib/types.d.ts +163 -6
  182. package/packages/service/lib/types.js +1 -1
  183. package/packages/service/package.json +2 -2
  184. package/packages/tool/lib/index.d.ts +1 -1
  185. package/packages/tool/lib/index.js +1 -1
  186. package/packages/tool/package.json +2 -2
  187. package/packages/ui/lib/config.d.ts +12 -2
  188. package/packages/ui/lib/config.js +18 -2
  189. package/packages/ui/lib/deps.d.ts +1 -1
  190. package/packages/ui/lib/deps.js +1 -1
  191. package/packages/ui/lib/form.d.ts +1 -1
  192. package/packages/ui/lib/form.js +1 -1
  193. package/packages/ui/lib/index.d.ts +7 -1
  194. package/packages/ui/lib/index.js +12 -2
  195. package/packages/ui/lib/meter.d.ts +1 -1
  196. package/packages/ui/lib/meter.js +1 -1
  197. package/packages/ui/lib/ops/engine.d.ts +142 -34
  198. package/packages/ui/lib/ops/engine.js +469 -265
  199. package/packages/ui/lib/ops/get-steps.d.ts +98 -0
  200. package/packages/ui/lib/ops/get-steps.js +359 -0
  201. package/packages/ui/lib/ops/hf.d.ts +26 -11
  202. package/packages/ui/lib/ops/hf.js +115 -25
  203. package/packages/ui/lib/ops/logs.d.ts +70 -15
  204. package/packages/ui/lib/ops/logs.js +96 -35
  205. package/packages/ui/lib/ops/model.d.ts +2 -10
  206. package/packages/ui/lib/ops/model.js +9 -16
  207. package/packages/ui/lib/request.d.ts +1 -1
  208. package/packages/ui/lib/request.js +1 -1
  209. package/packages/ui/lib/route.d.ts +22 -6
  210. package/packages/ui/lib/route.js +32 -9
  211. package/packages/ui/lib/routes/chat-template.d.ts +1 -14
  212. package/packages/ui/lib/routes/chat-template.js +1 -37
  213. package/packages/ui/lib/routes/deps.d.ts +1 -16
  214. package/packages/ui/lib/routes/deps.js +1 -27
  215. package/packages/ui/lib/routes/download.d.ts +1 -16
  216. package/packages/ui/lib/routes/download.js +1 -48
  217. package/packages/ui/lib/routes/get.d.ts +1 -18
  218. package/packages/ui/lib/routes/get.js +66 -17
  219. package/packages/ui/lib/routes/install.d.ts +1 -1
  220. package/packages/ui/lib/routes/install.js +1 -1
  221. package/packages/ui/lib/routes/launch.d.ts +13 -7
  222. package/packages/ui/lib/routes/launch.js +33 -9
  223. package/packages/ui/lib/routes/logs.d.ts +57 -0
  224. package/packages/ui/lib/routes/logs.js +117 -0
  225. package/packages/ui/lib/routes/provider-delete.d.ts +1 -1
  226. package/packages/ui/lib/routes/provider-delete.js +1 -1
  227. package/packages/ui/lib/routes/register.d.ts +1 -1
  228. package/packages/ui/lib/routes/register.js +1 -1
  229. package/packages/ui/lib/routes/save.d.ts +1 -1
  230. package/packages/ui/lib/routes/save.js +22 -2
  231. package/packages/ui/lib/routes/stop.d.ts +7 -4
  232. package/packages/ui/lib/routes/stop.js +7 -4
  233. package/packages/ui/lib/routes/stream.d.ts +40 -0
  234. package/packages/ui/lib/routes/stream.js +68 -0
  235. package/packages/ui/lib/routes/summary.d.ts +1 -1
  236. package/packages/ui/lib/routes/summary.js +1 -1
  237. package/packages/ui/lib/routes/unregister.d.ts +1 -1
  238. package/packages/ui/lib/routes/unregister.js +1 -1
  239. package/packages/ui/lib/viewmodels/deps.d.ts +1 -31
  240. package/packages/ui/lib/viewmodels/deps.js +1 -89
  241. package/packages/ui/lib/viewmodels/depsHelpers.d.ts +1 -28
  242. package/packages/ui/lib/viewmodels/depsHelpers.js +72 -11
  243. package/packages/ui/lib/viewmodels/summary.d.ts +7 -2
  244. package/packages/ui/lib/viewmodels/summary.js +55 -35
  245. package/packages/ui/lib/viewmodels/summaryHelpers.d.ts +1 -1
  246. package/packages/ui/lib/viewmodels/summaryHelpers.js +1 -1
  247. package/packages/ui/lib/warnings.d.ts +1 -1
  248. package/packages/ui/lib/warnings.js +1 -1
  249. package/packages/ui/package.json +4 -4
  250. package/packages/providers/lib/engines/strata.d.ts +0 -53
@@ -17,7 +17,7 @@
17
17
  * mod-search bridge.
18
18
  * @module dsh-plugin-local-infer/service
19
19
  */
20
- import { exec, execFile, spawn } from 'node:child_process';
20
+ import { execFile, spawn } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { availableParallelism, cpus, homedir, platform, release, totalmem } from 'node:os';
23
23
  import { dirname, join } from 'node:path';
@@ -25,7 +25,10 @@ import { Service } from '@deepseek-ai/cordis';
25
25
  // Relative, not the root package subpath: a self-referencing bare specifier
26
26
  // only resolves when the package sits inside a node_modules tree, so a
27
27
  // local-path or pnpm-linked install of this repo would fail the import.
28
- import { AGENT_DEFAULT_MODEL_NAMESPACE, agentDefaultOps, agentDefaultPointsAt, agentDefaultUnsetOps, buildModelEntry, detectHardware as detectHardwareFromProbes, detectPackageManager, evaluateGuidelines, estimateVram, hfChatTemplateCommand, hfDownloadCommand, isGpuBusy, LLM_PI_AI_NAMESPACE, matchLaunchSignature, modelGuidelines, reLinkRecord, computeProviderStatuses, modelRowsFrom, parseNvidiaSmiGpu, parseNvidiaSmiGpuUsage, presetFromFetch, providerRegistrationOps, providerRemovalOps, removeModelRow, renderInstallString, resolveDefaults as resolveDefaultsFromPresets, resolveOsReserve, runInstall, } from '../../core/lib/index.js';
28
+ import { AGENT_DEFAULT_MODEL_NAMESPACE, agentDefaultOps, agentDefaultPointsAt, agentDefaultUnsetOps, buildModelEntry, commandLogLine, detectHardware as detectHardwareFromProbes, detectPackageManager, evaluateGuidelines, estimateVram, guardSudoCommand, hfChatTemplateCommand, hfDatasetsCacheDirectory, hfDownloadCommand, hfHubCacheDirectory, isGpuBusy, LLM_PI_AI_NAMESPACE, matchLaunchSignature, modelGuidelines, reLinkRecord, computeProviderStatuses, modelRowsFrom, parseNvidiaSmiGpu, parseNvidiaSmiGpuUsage, presetFromFetch, providerRegistrationOps, providerRemovalOps, removeModelRow, renderInstallString, resolveDefaults as resolveDefaultsFromPresets, resolveOsReserve, runInstall, SUDO_PROBE_COMMAND, sudoFailureDetail, } from '../../core/lib/index.js';
29
+ import { RunLogStore, runLogKey } from "./run-logs.js";
30
+ import { findLaunchTarget } from "./launch-target.js";
31
+ export { RunLogStore, runLogKey, findLaunchTarget };
29
32
  /**
30
33
  * Local-inference failure. Extends Error with a stable `code`
31
34
  * (`LOCAL_INFER_CONFLICT`, `LOCAL_INFER_INVALID_ENGINE`,
@@ -46,6 +49,13 @@ export class LocalInferError extends Error {
46
49
  * "not available" instead.
47
50
  */
48
51
  const PROBE_TIMEOUT_MS = 5000;
52
+ /**
53
+ * The page-fetch deadline the modsearch CLI is told to cap itself with (its
54
+ * --deadline argument, milliseconds as text): the CLI kills its own fetch
55
+ * past this, so the spawned bridge process returns instead of sitting on an
56
+ * unreachable URL.
57
+ */
58
+ const MODSEARCH_DEADLINE_ARG = '60000';
49
59
  /**
50
60
  * The adopted-process poll interval. A re-adopted engine (a surviving process
51
61
  * with no ChildProcess after a DSH restart) is watched for death through a
@@ -77,8 +87,8 @@ export class LocalInfer extends Service {
77
87
  // confirm which model a live engine serves: a port the plugin launched reads
78
88
  // as that model, a port it did not launch (a foreign engine) has no entry and
79
89
  // reads as "engine running, model unknown". The map resets on a host
80
- // restart, so a fresh start tracks only the launches it made itself. See
81
- // PLAN.md for the PID-cache note: a full PID cache is a future refinement.
90
+ // restart, so a fresh start tracks only the launches it made itself. A full
91
+ // PID cache is a future refinement.
82
92
  launchedModels = new Map();
83
93
  // The live ports tracked per launched engine (launches and adopted re-links
84
94
  // both register). The death handler clears the engine's plugin-launched flag
@@ -113,6 +123,18 @@ export class LocalInfer extends Service {
113
123
  // provider entry's cleared state; a headless profile registers none and the
114
124
  // service still settles its own live state.
115
125
  deathObserver;
126
+ // The run-registered hooks for sudo commands the guard found waiting for a
127
+ // password on the CLI, one slot per run key (the runLogKey rule every run
128
+ // path shares). A guarded command announces through notifySudoNeeded to
129
+ // every registered observer (one CLI password wait blocks every plugin
130
+ // run), so concurrent runs keep their own note routing and a settle
131
+ // clears only its own slot. A headless profile registers none and the
132
+ // guard still announces internally and caps the wait.
133
+ sudoObservers = new Map();
134
+ // The shared per-run live log hub every provider process writes its output
135
+ // to, keyed by the runLogKey rule so the run paths and the streaming logs
136
+ // route predict one key per provider.
137
+ runLogs = new RunLogStore();
116
138
  constructor(ctx) {
117
139
  super(ctx, 'localInfer');
118
140
  }
@@ -391,14 +413,16 @@ export class LocalInfer extends Service {
391
413
  return statuses;
392
414
  }
393
415
  /**
394
- * unsureBannerKillCommands returns the bash kill commands for the engine
416
+ * unsureBannerKillCommands returns the stop commands for the engine
395
417
  * processes the detector saw holding VRAM, for the "unsure" banner the card
396
418
  * shows when no provider is confirmed running but at least one reads as
397
419
  * unsure. A process is included when its command line carries an engine
398
420
  * binary marker and the card's VRAM usage is above 30% of the total (the
399
421
  * threshold below which a foreign engine is not considered to be holding
400
- * the card). The commands are one `kill <pid>` per matching process, in
401
- * pid order. An empty list means no matching process was seen, so the
422
+ * the card). The command form switches on the host OS: one
423
+ * `Stop-Process -Id <pid>` per matching process on Windows (the PowerShell
424
+ * cmdlet the banner's text area runs), one `kill <pid>` on Linux and macOS,
425
+ * in pid order. An empty list means no matching process was seen, so the
402
426
  * banner's text area renders empty.
403
427
  */
404
428
  async unsureBannerKillCommands(options) {
@@ -418,10 +442,13 @@ export class LocalInfer extends Service {
418
442
  gpuUsage.usedMiB / gpuUsage.totalMiB < 0.3) {
419
443
  return [];
420
444
  }
445
+ // The stop command form follows the host OS: Stop-Process on Windows,
446
+ // kill on Linux and macOS.
447
+ const isWindows = platform() === 'win32';
421
448
  const commands = [];
422
449
  for (const line of processes) {
423
450
  if (markers.some((marker) => line.command.includes(marker))) {
424
- commands.push(`kill ${line.pid}`);
451
+ commands.push(isWindows ? `Stop-Process -Id ${line.pid}` : `kill ${line.pid}`);
425
452
  }
426
453
  }
427
454
  return commands;
@@ -451,14 +478,27 @@ export class LocalInfer extends Service {
451
478
  * deps resolves the host deps an engine's deploy step needs for a build
452
479
  * mode. A registered adapter that declares systemDeps wins; the provider-
453
480
  * registered plan for the engine id is the fallback, and an engine with
454
- * neither plans an empty list (nothing to install, the gate passes).
481
+ * neither plans an empty list (nothing to install, the gate passes). The
482
+ * local list is per-OS: a Windows host reads the plan's windows entry (the
483
+ * winget toolchain the providers sourced from the Windows profile), every
484
+ * other host reads the linux entry, so the System dependencies section
485
+ * shows the right toolchain for the host.
455
486
  */
456
487
  async deps(engineId, buildMode, hw) {
457
488
  const adapter = this.engineRegistry.get(engineId);
458
489
  if (adapter !== undefined && typeof adapter.systemDeps === 'function') {
459
490
  return adapter.systemDeps(buildMode, hw);
460
491
  }
461
- return this.depPlanRegistry.get(engineId)?.[buildMode] ?? [];
492
+ const plan = this.depPlanRegistry.get(engineId);
493
+ if (plan === undefined)
494
+ return [];
495
+ if (buildMode === 'local') {
496
+ // A Windows host reads the windows entry; every other host (linux,
497
+ // macos) reads the linux entry, which the providers source from the
498
+ // shared Linux profile.
499
+ return plan.local?.[hw.os] ?? plan.local?.linux ?? [];
500
+ }
501
+ return plan[buildMode] ?? [];
462
502
  }
463
503
  /**
464
504
  * installString renders the copy/paste block for the resolved dep plan,
@@ -471,12 +511,29 @@ export class LocalInfer extends Service {
471
511
  }
472
512
  /**
473
513
  * runInstall is the Play/Run path: it runs the resolved dep plan's commands
474
- * through the shell runner and re-probes each dep. The runner captures the
475
- * per-command exit, so a failure names the command that broke.
514
+ * through the guarded shell runner and re-probes each dep. The runner
515
+ * captures the per-command exit, so a failure names the command that broke,
516
+ * and a declared-sudo command whose probe finds no cached credentials
517
+ * announces the CLI prompt through the sudo observer and fails at the
518
+ * sudo deadline. options.onLog is the install run's log sink: the command
519
+ * line reaches it as a `$ <command>` line before the guarded runner spawns
520
+ * the child (and before the sudo guard's probe announces), so the run text
521
+ * carries the command ahead of the per-dep results. options.runner injects
522
+ * the command runner so a test reads the command lines without a real shell.
476
523
  */
477
- async runInstall(engineId, buildMode, hw) {
524
+ async runInstall(engineId, buildMode, hw, options) {
478
525
  const deps = await this.deps(engineId, buildMode, hw);
479
- return runInstall(deps, hw, shellRunCommand);
526
+ // The guarded runner is built per call so every sudo announcement
527
+ // reaches the observers registered when this call runs.
528
+ const runner = options?.runner ?? guardedShellRunner((command) => this.notifySudoNeeded(command));
529
+ // The audit wrapper logs each command the core provisioner actually runs
530
+ // (a satisfied dep never runs, so it never logs) before delegating to the
531
+ // runner. Keeping the wrapper here leaves the core provisioner pure.
532
+ const loggedRunner = (command, runOptions) => {
533
+ options?.onLog?.(commandLogLine(command));
534
+ return runner(command, runOptions);
535
+ };
536
+ return runInstall(deps, hw, loggedRunner);
480
537
  }
481
538
  /**
482
539
  * launch is the lifecycle entry point: it resolves the adapter that rendered
@@ -501,11 +558,14 @@ export class LocalInfer extends Service {
501
558
  // whether the git sync (fetch, rebase, rebuild) runs at all.
502
559
  // exactOptionalPropertyTypes: only pass the option object when a key is
503
560
  // present.
504
- const hasDeployOptions = options?.allowRunDeps !== undefined || options?.autoRebuild !== undefined;
561
+ const hasDeployOptions = options?.allowRunDeps !== undefined ||
562
+ options?.autoRebuild !== undefined ||
563
+ options?.onLog !== undefined;
505
564
  const deployOptions = hasDeployOptions
506
565
  ? {
507
566
  ...(options?.allowRunDeps !== undefined ? { allowRunDeps: options.allowRunDeps } : {}),
508
567
  ...(options?.autoRebuild !== undefined ? { autoRebuild: options.autoRebuild } : {}),
568
+ ...(options?.onLog !== undefined ? { onLog: options.onLog } : {}),
509
569
  }
510
570
  : undefined;
511
571
  // The in-flight launch flag: the provider-status detector keeps this
@@ -665,6 +725,86 @@ export class LocalInfer extends Service {
665
725
  setEngineDeathObserver(observer) {
666
726
  this.deathObserver = observer;
667
727
  }
728
+ /**
729
+ * registerSudoObserver reserves `key`'s slot (the runLogKey rule every run
730
+ * path shares) for one run's hook for commands the sudo guard found
731
+ * waiting for a password on the launching CLI: the observer lands core's
732
+ * SUDO_CLI_NOTE line while the command waits. Registering a key that is
733
+ * taken replaces the old hook (a re-launch under the same key owns the
734
+ * note), and the disposer removes only its own hook, so a stale run's
735
+ * settle never clears a live one's routing. The run calls the disposer in
736
+ * a finally.
737
+ */
738
+ registerSudoObserver(key, observer) {
739
+ this.sudoObservers.set(key, observer);
740
+ return () => {
741
+ if (this.sudoObservers.get(key) === observer) {
742
+ this.sudoObservers.delete(key);
743
+ }
744
+ };
745
+ }
746
+ /**
747
+ * notifySudoNeeded announces one guarded command to every registered sudo
748
+ * observer (the shared CLI password wait can block any run, and the
749
+ * announce seam does not name the run that triggered it). A throwing
750
+ * observer is isolated per announce: the note is a log courtesy, and a
751
+ * broken route must not fail the install that is already racing the sudo
752
+ * deadline. Published on the service surface so a command runner outside
753
+ * this package (the providers' lifecycle host) reports through the same
754
+ * seam.
755
+ */
756
+ notifySudoNeeded(command) {
757
+ for (const observer of [...this.sudoObservers.values()]) {
758
+ try {
759
+ observer(command);
760
+ }
761
+ catch {
762
+ // The sudo deadline still settles a passwordless wait.
763
+ }
764
+ }
765
+ }
766
+ /**
767
+ * beginRun starts one provider process's live run-log run under the key
768
+ * runLogKey names: the buffer resets to the seed separator line, the
769
+ * running flag goes true, the seq restarts, and subscribers get the reset
770
+ * notice a mid-run attached reader needs to replace its display.
771
+ */
772
+ beginRun(key, seed) {
773
+ this.runLogs.begin(key, seed);
774
+ }
775
+ /**
776
+ * appendRunLog appends one chunk to the key's live log (tail-capped to
777
+ * LOG_TAIL_BYTES) and notifies subscribers. Appending with no open run
778
+ * opens one lazily without a seed, so a late note (an engine-death line)
779
+ * still lands.
780
+ */
781
+ appendRunLog(key, text) {
782
+ this.runLogs.append(key, text);
783
+ }
784
+ /**
785
+ * readRunLog answers the key's catch-up snapshot: the seq reached, the
786
+ * bounded tail text, and the running flag, the data a mid-run page
787
+ * refresh or a late-opened Logs pane renders from.
788
+ */
789
+ readRunLog(key) {
790
+ return this.runLogs.read(key);
791
+ }
792
+ /**
793
+ * subscribeRunLog registers a listener for the key's run-log events and
794
+ * returns the disposer (the route calls it when its stream closes).
795
+ * Listeners are independent per connection; none can block a writer.
796
+ */
797
+ subscribeRunLog(key, listener) {
798
+ return this.runLogs.subscribe(key, listener);
799
+ }
800
+ /**
801
+ * endRun settles the key's run: the running flag clears and subscribers
802
+ * get the end event. The text stays readable until the next beginRun, so
803
+ * a late Logs pane still shows the finished run.
804
+ */
805
+ endRun(key) {
806
+ this.runLogs.end(key);
807
+ }
668
808
  /**
669
809
  * watchLaunchedHandle ties a launched child's one-shot exit to the live
670
810
  * state: the host's close/error event (a crash, a pkill, or the stop kill)
@@ -782,9 +922,12 @@ export class LocalInfer extends Service {
782
922
  /**
783
923
  * stop signals the engine a launch started: it resolves the adapter by the
784
924
  * plan's engineId and runs the adapter's stop step (kill the tracked process,
785
- * stop the named container, or compose down).
925
+ * stop the named container, or compose down). The optional `log` is the
926
+ * stop run's log sink: the adapter writes the `$ <command>` line of every
927
+ * command it runs (compose down, docker stop, a pid signal) through it
928
+ * before the child runs, so the stop's live run reads the command first.
786
929
  */
787
- async stop(plan) {
930
+ async stop(plan, log) {
788
931
  const adapter = this.engineRegistry.get(plan.engineId);
789
932
  if (adapter === undefined) {
790
933
  throw new LocalInferError(`no engine adapter is registered for "${plan.engineId}"`, 'LOCAL_INFER_INVALID_ENGINE');
@@ -794,8 +937,18 @@ export class LocalInfer extends Service {
794
937
  // flashes the "Unable to link" banner for the moment the process takes to
795
938
  // die. The flag clears when the process dies (the death observer) or the
796
939
  // detector sees the process down (a stop of an engine it cannot watch).
797
- this.stoppingEngines.add(this.engineKey(plan.engineId, plan.port));
798
- await adapter.stop(plan);
940
+ const stopKey = this.engineKey(plan.engineId, plan.port);
941
+ this.stoppingEngines.add(stopKey);
942
+ try {
943
+ await adapter.stop(plan, log);
944
+ }
945
+ catch (error) {
946
+ // A stop that failed (a kill the host could not deliver) settles the
947
+ // in-flight state: the flag clears, so the provider reads from the
948
+ // process state, not a stop stuck in flight.
949
+ this.stoppingEngines.delete(stopKey);
950
+ throw error;
951
+ }
799
952
  // Clear the live-port ledger and the plugin-launched flag, and cancel any
800
953
  // adopted-process watcher standing on the port. The port-to-model tie is
801
954
  // deferred to the death observer, so the provider keeps its linked read
@@ -910,7 +1063,10 @@ export class LocalInfer extends Service {
910
1063
  preset: undefined,
911
1064
  stored: false,
912
1065
  degraded: true,
913
- detail: `the modsearch fetch failed: ${detail}; the built-in presets apply`,
1066
+ // The URL rides the caller-visible detail: fetchPreset is not a
1067
+ // provider process (no run-log key to write a `> GET` line into), so
1068
+ // the audited rule is served by naming the queried URL here instead.
1069
+ detail: `the modsearch fetch failed for ${source.url}: ${detail}; the built-in presets apply`,
914
1070
  };
915
1071
  }
916
1072
  const preset = presetFromFetch(source, content, todayIso());
@@ -919,13 +1075,26 @@ export class LocalInfer extends Service {
919
1075
  return { preset, stored: true, degraded: false };
920
1076
  }
921
1077
  /**
922
- * defaultModelDirectory is the plugin's model download directory: under
923
- * the dsh home so a web user and a CLI user share one location, the way the
924
- * config file does. The directory is created when absent, so the field's
925
- * default is always a real path the deploy step can target.
1078
+ * defaultModelDirectory is the plugin's model download directory: the
1079
+ * Hugging Face hub cache the hf CLI and huggingface_hub share (HF_HUB_CACHE
1080
+ * when set, then $HF_HOME/hub, then ~/.cache/huggingface/hub), so a model
1081
+ * the plugin pulls lands where every other HF tooling looks. The directory
1082
+ * is created when absent, so the field's default is always a real path the
1083
+ * deploy step can target.
926
1084
  */
927
1085
  defaultModelDirectory() {
928
- const directory = join(homedir(), '.dsh', 'models');
1086
+ const directory = hfHubCacheDirectory();
1087
+ mkdirSync(directory, { recursive: true });
1088
+ return directory;
1089
+ }
1090
+ /**
1091
+ * defaultDatasetDirectory is the plugin's dataset download directory: the
1092
+ * Hugging Face datasets cache (HF_DATASETS_CACHE when set, then
1093
+ * $HF_HOME/datasets, then ~/.cache/huggingface/datasets). Created when
1094
+ * absent, for the same reason the model directory is.
1095
+ */
1096
+ defaultDatasetDirectory() {
1097
+ const directory = hfDatasetsCacheDirectory();
929
1098
  mkdirSync(directory, { recursive: true });
930
1099
  return directory;
931
1100
  }
@@ -938,7 +1107,10 @@ export class LocalInfer extends Service {
938
1107
  const injected = options?.cliPath;
939
1108
  if (injected !== undefined && injected.trim() !== '')
940
1109
  return injected;
941
- const which = await runCommand('which', ['hf']);
1110
+ // The presence probe is `which` on Linux/macOS and `where` on Windows, so
1111
+ // an installed hf CLI is found on every host.
1112
+ const probe = platform() === 'win32' ? 'where' : 'which';
1113
+ const which = await runCommand(probe, ['hf']);
942
1114
  if (which.ok && which.stdout.trim() !== '')
943
1115
  return which.stdout.trim();
944
1116
  return null;
@@ -947,7 +1119,10 @@ export class LocalInfer extends Service {
947
1119
  * downloadModel runs the hf CLI download for one default model into the
948
1120
  * model directory: it creates the target directory when absent (the
949
1121
  * recursive flag covers the parents too), renders the command through the
950
- * core builder (the single source the card shows), and runs it through the
1122
+ * core builder (the single source the card shows), logs the rendered
1123
+ * command's `$ <command>` line through options.onLog before the runner
1124
+ * spawns the child (so every caller's run reads command first, output
1125
+ * after, and the line is written once, here), and runs it through the
951
1126
  * injected runner when one is given, so a test captures the command without
952
1127
  * a live hf. A failure keeps the runner's detail, so the card names the
953
1128
  * broken step.
@@ -955,7 +1130,9 @@ export class LocalInfer extends Service {
955
1130
  async downloadModel(entry, directory, options) {
956
1131
  mkdirSync(directory, { recursive: true });
957
1132
  const runner = options?.runner ?? downloadRunner;
958
- const result = await runner(hfDownloadCommand(entry, directory));
1133
+ const command = hfDownloadCommand(entry, directory, options?.useHubCache === true);
1134
+ options?.onLog?.(commandLogLine(command));
1135
+ const result = await runner(command, options?.signal, options?.onChunk);
959
1136
  if (result.ok)
960
1137
  return { ok: true };
961
1138
  return { ok: false, detail: `model download failed: ${result.detail ?? 'unknown'}` };
@@ -964,13 +1141,16 @@ export class LocalInfer extends Service {
964
1141
  * downloadChatTemplate pulls the chat template file for one HF repo
965
1142
  * reference into the model directory, so a selected template (the Froggeric
966
1143
  * set, a custom repo) works out of the box. It shares the model download's
967
- * recursive directory creation, runner, and long timeout: a template is a
968
- * small file, but the hf CLI's cold start and auth are the same cost.
1144
+ * recursive directory creation, runner, log line before the spawn, and long
1145
+ * timeout: a template is a small file, but the hf CLI's cold start and auth
1146
+ * are the same cost.
969
1147
  */
970
1148
  async downloadChatTemplate(repo, directory, options) {
971
1149
  mkdirSync(directory, { recursive: true });
972
1150
  const runner = options?.runner ?? downloadRunner;
973
- const result = await runner(hfChatTemplateCommand(repo, directory));
1151
+ const command = hfChatTemplateCommand(repo, directory, options?.useHubCache === true);
1152
+ options?.onLog?.(commandLogLine(command));
1153
+ const result = await runner(command, options?.signal, options?.onChunk);
974
1154
  if (result.ok)
975
1155
  return { ok: true };
976
1156
  return { ok: false, detail: `chat template download failed: ${result.detail ?? 'unknown'}` };
@@ -1020,6 +1200,71 @@ export class LocalInfer extends Service {
1020
1200
  }
1021
1201
  return entries;
1022
1202
  }
1203
+ /**
1204
+ * listModelCache lists the hub cache's `models--*` directories, the data the
1205
+ * per-engine model dropdown's cache scan draws from when the model directory
1206
+ * is the plugin default (the hub cache). Each entry carries the repo id the
1207
+ * directory names (the `{namespace}/{name}` decoded from the
1208
+ * `models--{ns}--{name}` form) and the artifact file names its snapshots
1209
+ * hold (the union across every commit). A missing or unreadable cache reads
1210
+ * as an empty list, which the route renders as "no cached models".
1211
+ */
1212
+ listModelCache() {
1213
+ const hub = hfHubCacheDirectory();
1214
+ let dirents;
1215
+ try {
1216
+ dirents = readdirSync(hub, { withFileTypes: true });
1217
+ }
1218
+ catch {
1219
+ return [];
1220
+ }
1221
+ const entries = [];
1222
+ for (const dirent of dirents) {
1223
+ if (!dirent.isDirectory() || !dirent.name.startsWith('models--'))
1224
+ continue;
1225
+ // Decode models--{ns}--{name} back to {ns}/{name}: the name is the
1226
+ // segment after the last --, the namespace everything before it.
1227
+ const body = dirent.name.slice('models--'.length);
1228
+ const separator = body.lastIndexOf('--');
1229
+ if (separator <= 0)
1230
+ continue;
1231
+ const repoId = `${body.slice(0, separator)}/${body.slice(separator + 2)}`;
1232
+ entries.push({ repoId, files: this.snapshotFiles(hub, dirent.name) });
1233
+ }
1234
+ return entries;
1235
+ }
1236
+ /**
1237
+ * snapshotFiles unions the file names across every commit directory under a
1238
+ * `models--*` dir's `snapshots/`, the artifact set the per-engine cache scan
1239
+ * filters. A missing snapshots folder reads as an empty set.
1240
+ */
1241
+ snapshotFiles(hub, modelDir) {
1242
+ const snapshots = join(hub, modelDir, 'snapshots');
1243
+ let commits;
1244
+ try {
1245
+ commits = readdirSync(snapshots, { withFileTypes: true });
1246
+ }
1247
+ catch {
1248
+ return [];
1249
+ }
1250
+ const files = new Set();
1251
+ for (const commit of commits) {
1252
+ if (!commit.isDirectory())
1253
+ continue;
1254
+ let commitFiles;
1255
+ try {
1256
+ commitFiles = readdirSync(join(snapshots, commit.name), { withFileTypes: true });
1257
+ }
1258
+ catch {
1259
+ continue;
1260
+ }
1261
+ for (const file of commitFiles) {
1262
+ if (file.isFile())
1263
+ files.add(file.name);
1264
+ }
1265
+ }
1266
+ return [...files];
1267
+ }
1023
1268
  /**
1024
1269
  * specsForEngine reads the parameter surface of the registered adapter for
1025
1270
  * an engine id, so the defaults resolver floors the result on that engine's
@@ -1044,7 +1289,9 @@ export class LocalInfer extends Service {
1044
1289
  const env = process.env.MODSEARCH_CLI;
1045
1290
  if (env !== undefined && env.trim() !== '')
1046
1291
  return env;
1047
- const which = await runCommand('which', ['modsearch']);
1292
+ // The presence probe is `which` on Linux/macOS and `where` on Windows.
1293
+ const probe = platform() === 'win32' ? 'where' : 'which';
1294
+ const which = await runCommand(probe, ['modsearch']);
1048
1295
  if (which.ok && which.stdout.trim() !== '')
1049
1296
  return which.stdout.trim();
1050
1297
  return null;
@@ -1057,7 +1304,7 @@ export class LocalInfer extends Service {
1057
1304
  */
1058
1305
  fetchPage(url, cliPath) {
1059
1306
  return new Promise((resolve, reject) => {
1060
- const child = spawn('node', [cliPath, 'search', '-u', url, '--deadline', '60000'], { stdio: ['ignore', 'pipe', 'pipe'] });
1307
+ const child = spawn('node', [cliPath, 'search', '-u', url, '--deadline', MODSEARCH_DEADLINE_ARG], { stdio: ['ignore', 'pipe', 'pipe'] });
1061
1308
  let stdout = '';
1062
1309
  let stderr = '';
1063
1310
  child.stdout.on('data', (chunk) => {
@@ -1313,36 +1560,132 @@ function runCommand(cmd, args) {
1313
1560
  */
1314
1561
  const INSTALL_OUTPUT_TAIL = 1024;
1315
1562
  /**
1316
- * shellRunCommand is the Play/Run runner: it runs one command line through
1317
- * the system shell and reports the exit. stdin is closed, so a sudo password
1318
- * prompt that cannot read a terminal fails fast instead of hanging the panel;
1319
- * the copy/paste path is the fallback the plan names for exactly this.
1563
+ * The high-water mark the streaming output trims to: the collected output
1564
+ * passes the mark only for a chatty command, and the trim keeps the last
1565
+ * kilobyte the failure detail shows, the same rule the providers' streaming
1566
+ * host applies.
1320
1567
  */
1321
- const shellRunCommand = (command) => new Promise(resolve => {
1322
- const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
1568
+ const INSTALL_OUTPUT_HIGH_WATER = INSTALL_OUTPUT_TAIL * 2;
1569
+ /**
1570
+ * tailOutput keeps a bounded tail of a streamed command's combined output:
1571
+ * append grows the buffer and trims it to the high-water mark, and tail
1572
+ * returns the trimmed last kilobyte a failure detail shows. The install and
1573
+ * download runners share it, so a chatty failure stays readable without
1574
+ * growing the host's memory.
1575
+ */
1576
+ function tailOutput() {
1323
1577
  let output = '';
1324
- const append = (chunk) => {
1325
- output += chunk.toString('utf8');
1326
- if (output.length > INSTALL_OUTPUT_TAIL * 2) {
1327
- output = output.slice(-INSTALL_OUTPUT_TAIL * 2);
1328
- }
1578
+ return {
1579
+ append: (text) => {
1580
+ output += text;
1581
+ if (output.length > INSTALL_OUTPUT_HIGH_WATER) {
1582
+ output = output.slice(-INSTALL_OUTPUT_HIGH_WATER);
1583
+ }
1584
+ },
1585
+ tail: () => output.trim().slice(-INSTALL_OUTPUT_TAIL),
1329
1586
  };
1330
- child.stdout.on('data', append);
1331
- child.stderr.on('data', append);
1332
- // Without a listener, a child that fails to start (a missing shell) emits
1333
- // an unlistened 'error' and throws it as an uncaughtException through the
1334
- // host. Read it as a failed command instead.
1335
- child.on('error', (error) => {
1336
- resolve({ ok: false, detail: error.message });
1587
+ }
1588
+ /**
1589
+ * sudoProbe answers whether sudo already holds cached (or NOPASSWD)
1590
+ * credentials: it runs SUDO_PROBE_COMMAND non-interactively through the
1591
+ * probe timeout and reads a clean exit as "no prompt is coming". A refused
1592
+ * -n, a missing sudo, and a hung probe all read as "a prompt is coming",
1593
+ * the safe side: the run announces and caps instead of dead-locking on a
1594
+ * TTY the card user cannot see.
1595
+ */
1596
+ export async function sudoProbe() {
1597
+ const [command, ...args] = SUDO_PROBE_COMMAND;
1598
+ if (command === undefined)
1599
+ return false;
1600
+ const { ok } = await runCommand(command, [...args]);
1601
+ return ok;
1602
+ }
1603
+ /**
1604
+ * sudoGuardFor is the service-side binding of core's guardSudoCommand: it
1605
+ * supplies sudoProbe, so this package's install runner and the providers'
1606
+ * lifecycle host drive one probe and one rule from one function. Calling
1607
+ * it is itself the sudo declaration (needsSudo is true); callers gate on
1608
+ * their own needsSudo fact first and never run a plain command through it.
1609
+ */
1610
+ export function sudoGuardFor(command, notify, kill, timeoutMs) {
1611
+ return guardSudoCommand({
1612
+ needsSudo: true,
1613
+ command,
1614
+ probe: sudoProbe,
1615
+ notify,
1616
+ kill,
1617
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
1337
1618
  });
1338
- child.on('close', code => {
1339
- if (code === 0) {
1340
- resolve({ ok: true });
1341
- return;
1619
+ }
1620
+ /**
1621
+ * guardedShellRunner builds the Play/Run runner: it runs one command line
1622
+ * through the system shell and reports the exit. stdin stays closed. When
1623
+ * core hands the command the dep's sudo declaration and sudoProbe finds no
1624
+ * cached credentials, core's guard announces the CLI prompt through notify
1625
+ * and arms the password deadline; its fire signals the child through the
1626
+ * shared signalChild rule (SIGINT, then SIGTERM; the group signal reaches a
1627
+ * sudo survivor of a compound line) and the run settles with
1628
+ * sudoFailureDetail instead of the shell's bare signal exit. The guard adds
1629
+ * only probe, notify, and deadline to the existing spawn; the copy/paste
1630
+ * path stays the fallback for a passwordless host.
1631
+ */
1632
+ export function guardedShellRunner(notify) {
1633
+ return async (command, options) => {
1634
+ let child;
1635
+ const guard = options?.needsSudo === true
1636
+ ? await sudoGuardFor(command, notify, () => {
1637
+ if (child !== undefined)
1638
+ signalChild(child);
1639
+ })
1640
+ : undefined;
1641
+ try {
1642
+ const outcome = await new Promise((resolve) => {
1643
+ // The local keeps the spawn overload's non-null stream typing; the
1644
+ // outer child exists for the guard's kill. The shell spawns detached
1645
+ // so it leads its own process group and signalChild's negative-pid
1646
+ // signal reaches the whole line. libuv's detached spawn only changes
1647
+ // the process group (Node >= 22 ships libuv >= 1.48, which does not
1648
+ // create a session), so a sudo prompt still reaches the launching
1649
+ // terminal and the guard's whole point survives the detach.
1650
+ const started = spawn(command, {
1651
+ shell: true,
1652
+ detached: true,
1653
+ stdio: ['ignore', 'pipe', 'pipe'],
1654
+ });
1655
+ child = started;
1656
+ detachedChildren.add(started);
1657
+ const tail = tailOutput();
1658
+ const append = (chunk) => {
1659
+ tail.append(chunk.toString('utf8'));
1660
+ };
1661
+ started.stdout.on('data', append);
1662
+ started.stderr.on('data', append);
1663
+ // Without a listener, a child that fails to start (a missing shell) emits
1664
+ // an unlistened 'error' and throws it as an uncaughtException through the
1665
+ // host. Read it as a failed command instead.
1666
+ started.on('error', (error) => {
1667
+ resolve({ ok: false, detail: error.message });
1668
+ });
1669
+ started.on('close', code => {
1670
+ if (code === 0) {
1671
+ resolve({ ok: true });
1672
+ return;
1673
+ }
1674
+ resolve({ ok: false, detail: `exit ${code}: ${tail.tail()}` });
1675
+ });
1676
+ });
1677
+ if (guard !== undefined && guard.timedOut() && !outcome.ok) {
1678
+ // The password deadline killed the child: name the missed sudo
1679
+ // prompt instead of the shell's signal exit.
1680
+ return { ok: false, detail: sudoFailureDetail(command) };
1681
+ }
1682
+ return outcome;
1342
1683
  }
1343
- resolve({ ok: false, detail: `exit ${code}: ${output.trim().slice(-INSTALL_OUTPUT_TAIL)}` });
1344
- });
1345
- });
1684
+ finally {
1685
+ guard?.disarm();
1686
+ }
1687
+ };
1688
+ }
1346
1689
  /**
1347
1690
  * The download timeout. A model download moves tens of GiB, so the cap is
1348
1691
  * hours, not the probe's seconds; a hung download must not kill the panel,
@@ -1350,20 +1693,126 @@ const shellRunCommand = (command) => new Promise(resolve => {
1350
1693
  */
1351
1694
  const DOWNLOAD_TIMEOUT_MS = 7_200_000;
1352
1695
  /**
1353
- * downloadRunner is the model-download runner: it runs the rendered hf CLI
1354
- * command through the system shell and reports the exit. The output tail keeps
1355
- * the last kilobyte, the same rule the install runner uses, so a failure names
1356
- * the broken line.
1696
+ * The cancel grace period in ms: an aborted download gets SIGINT first, and a
1697
+ * child still alive after the grace period gets SIGTERM, so a stuck hf CLI
1698
+ * cannot keep the run open.
1699
+ */
1700
+ const DOWNLOAD_ABORT_GRACE_MS = 5_000;
1701
+ /**
1702
+ * The install shells the guarded runner spawned detached (process-group
1703
+ * leaders). signalProcess reads the set so the negative-pid group signal
1704
+ * only ever targets a child that actually leads its group; a child that
1705
+ * stays in the host's group (the download runner's shell) gets the direct
1706
+ * signal, so a stray negative pid can never fan out beyond the child.
1357
1707
  */
1358
- const downloadRunner = (command) => new Promise(resolve => {
1359
- exec(command, { timeout: DOWNLOAD_TIMEOUT_MS }, (error, stdout, stderr) => {
1360
- if (error === null) {
1708
+ const detachedChildren = new WeakSet();
1709
+ /**
1710
+ * signalProcess delivers one signal to one child: a process-group leader
1711
+ * gets it through its negative pid, so a compound command line
1712
+ * (`a && sudo b`) dies whole instead of leaving sudo as a group survivor;
1713
+ * every other child gets the direct signal. A group that is already gone
1714
+ * (ESRCH) or a platform that rejects negative pids falls back to the direct
1715
+ * signal, which reaches the shell itself.
1716
+ */
1717
+ function signalProcess(child, signal) {
1718
+ if (child.pid !== undefined && detachedChildren.has(child)) {
1719
+ try {
1720
+ process.kill(-child.pid, signal);
1721
+ return;
1722
+ }
1723
+ catch {
1724
+ // Group gone or unsupported: the direct signal below still reaches the
1725
+ // shell, and a child that already exited no-ops the way ChildProcess
1726
+ // .kill always does.
1727
+ }
1728
+ }
1729
+ child.kill(signal);
1730
+ }
1731
+ /**
1732
+ * signalChild applies the cancel rule: SIGINT first, SIGTERM after the grace
1733
+ * period if the child is still alive. The child already marked killed or
1734
+ * exited is left alone. The optional graceMs overrides the 5 s constant so a
1735
+ * test can exercise the escalation without waiting. The abort watch and the
1736
+ * sudo deadline kill share this one rule, the mirror of the providers'
1737
+ * lifecycle host copy (packages/providers/src/adapters.ts, signalChild):
1738
+ * each package owns its child-process wiring, so a change to the escalation
1739
+ * rule lands in two places by design.
1740
+ */
1741
+ function signalChild(child, graceMs) {
1742
+ if (child.killed || child.exitCode !== null)
1743
+ return;
1744
+ signalProcess(child, 'SIGINT');
1745
+ const escalate = setTimeout(() => {
1746
+ // A group signal never marks ChildProcess.kill, so the guard reads the
1747
+ // exit state: a child that has neither exited nor been signalled is
1748
+ // still alive and gets SIGTERM.
1749
+ if (child.exitCode === null && child.signalCode === null)
1750
+ signalProcess(child, 'SIGTERM');
1751
+ }, graceMs ?? DOWNLOAD_ABORT_GRACE_MS);
1752
+ escalate.unref();
1753
+ }
1754
+ /**
1755
+ * watchChildAbort kills the in-flight child when the signal aborts (the card's
1756
+ * cancel): SIGINT first, SIGTERM after the grace period if the child is still
1757
+ * alive, through the shared signalChild rule. A signal that is already
1758
+ * aborted kills immediately. A near-identical copy lives in the providers
1759
+ * package (packages/providers/src/adapters.ts, the lifecycle host's run and
1760
+ * runStream abort path): each package owns its own child-process wiring, and
1761
+ * the guard fix (the SIGTERM escalation) was applied to both copies, so a
1762
+ * change to the escalation rule lands in two places by design.
1763
+ */
1764
+ function watchChildAbort(child, signal, graceMs) {
1765
+ if (signal === undefined)
1766
+ return;
1767
+ const onAbort = () => {
1768
+ signalChild(child, graceMs);
1769
+ };
1770
+ if (signal.aborted) {
1771
+ onAbort();
1772
+ return;
1773
+ }
1774
+ signal.addEventListener('abort', onAbort, { once: true });
1775
+ }
1776
+ /**
1777
+ * downloadRunner is the model-download runner: it spawns the rendered hf CLI
1778
+ * command through the system shell and reports the exit. The spawned child's
1779
+ * stdout and stderr chunks stream to onChunk as they land (the hf CLI's tqdm
1780
+ * progress), while the output tail keeps the last kilobyte, the same rule the
1781
+ * install runner uses, so a failure names the broken line. An abort on the
1782
+ * optional signal interrupts the in-flight child (SIGINT, then SIGTERM), so a
1783
+ * canceled download stops instead of running to the timeout. The optional
1784
+ * graceMs overrides the 5 s SIGTERM escalation window for the abort path.
1785
+ */
1786
+ export const downloadRunner = (command, signal, onChunk, graceMs) => new Promise(resolve => {
1787
+ const child = spawn(command, {
1788
+ shell: true,
1789
+ timeout: DOWNLOAD_TIMEOUT_MS,
1790
+ stdio: ['ignore', 'pipe', 'pipe'],
1791
+ });
1792
+ const tail = tailOutput();
1793
+ const append = (chunk) => {
1794
+ const text = chunk.toString('utf8');
1795
+ tail.append(text);
1796
+ onChunk?.(text);
1797
+ };
1798
+ child.stdout.on('data', append);
1799
+ child.stderr.on('data', append);
1800
+ // Without a listener, a child that fails to start (a missing shell) emits
1801
+ // an unlistened 'error' and throws it as an uncaughtException through the
1802
+ // host. Read it as a failed command instead.
1803
+ child.on('error', (error) => {
1804
+ resolve({ ok: false, detail: error.message });
1805
+ });
1806
+ child.on('close', (code, killSignal) => {
1807
+ if (code === 0) {
1361
1808
  resolve({ ok: true });
1362
1809
  return;
1363
1810
  }
1364
- const detail = `${stderr ?? ''}${stdout ?? ''}`.trim().slice(-INSTALL_OUTPUT_TAIL);
1365
- resolve({ ok: false, detail: detail === '' ? String(error) : detail });
1811
+ const detail = tail.tail();
1812
+ const label = code !== null ? `exit ${code}` : `killed by ${killSignal ?? 'a signal'}`;
1813
+ resolve({ ok: false, detail: detail === '' ? label : `${label}: ${detail}` });
1366
1814
  });
1815
+ watchChildAbort(child, signal, graceMs);
1367
1816
  });
1368
1817
  /**
1369
1818
  * hasSafetensorsIn reports whether one directory holds a `.safetensors`
@@ -1405,15 +1854,21 @@ async function probeGpuUsage() {
1405
1854
  return result.ok ? parseNvidiaSmiGpuUsage(result.stdout) : null;
1406
1855
  }
1407
1856
  /**
1408
- * probeProcesses reads the running process table (`ps -eo pid,args`) into
1409
- * process lines the launch-signature matcher consumes. The env element of a
1410
- * line is read from the Linux /proc environ, and only for a pid whose command
1411
- * line carries one of the engine markers: a full table of env reads is slow,
1412
- * and the matcher's env-key element only fires on a marker-matched process
1413
- * anyway. A host without /proc (macOS) degrades to env-less lines, which the
1414
- * matcher reads as a neutral env element.
1857
+ * probeProcesses reads the running process table into process lines the
1858
+ * launch-signature matcher consumes. The path switches on the host OS:
1859
+ * Windows reads the table through PowerShell's Win32_Process CIM class (the
1860
+ * pid and the full command line), and Linux and macOS read it through
1861
+ * `ps -eo pid,args`. The env element of a line is read from the Linux /proc
1862
+ * environ, and only for a pid whose command line carries one of the engine
1863
+ * markers: a full table of env reads is slow, and the matcher's env-key
1864
+ * element only fires on a marker-matched process anyway. A host without /proc
1865
+ * (macOS, Windows) degrades to env-less lines, which the matcher reads as a
1866
+ * neutral env element.
1415
1867
  */
1416
1868
  async function probeProcesses(markers) {
1869
+ if (platform() === 'win32') {
1870
+ return probeWindowsProcesses();
1871
+ }
1417
1872
  const result = await runCommand('ps', ['-eo', 'pid,args']);
1418
1873
  if (!result.ok)
1419
1874
  return [];
@@ -1436,6 +1891,51 @@ async function probeProcesses(markers) {
1436
1891
  }
1437
1892
  return lines;
1438
1893
  }
1894
+ /**
1895
+ * parseWindowsProcessTable reads the `pid<TAB>commandLine` rows PowerShell's
1896
+ * Win32_Process probe emits into process lines the launch-signature matcher
1897
+ * consumes. Each row is split on its first tab: the pid is the left field, the
1898
+ * command line the right. A row with no tab (a system process with an empty
1899
+ * command line, trimmed to its pid alone) is skipped, and a non-numeric pid is
1900
+ * dropped. The env element stays undefined: Windows has no /proc environ to
1901
+ * read, and the matcher reads a missing env as neutral.
1902
+ */
1903
+ export function parseWindowsProcessTable(stdout) {
1904
+ const lines = [];
1905
+ for (const raw of stdout.split('\n')) {
1906
+ const trimmed = raw.trim();
1907
+ if (trimmed === '')
1908
+ continue;
1909
+ const tab = trimmed.indexOf('\t');
1910
+ if (tab <= 0)
1911
+ continue;
1912
+ const pid = Number(trimmed.slice(0, tab));
1913
+ const command = trimmed.slice(tab + 1).trim();
1914
+ if (!Number.isFinite(pid) || command === '')
1915
+ continue;
1916
+ lines.push({ pid, command });
1917
+ }
1918
+ return lines;
1919
+ }
1920
+ /**
1921
+ * probeWindowsProcesses reads the running process table through PowerShell's
1922
+ * Win32_Process CIM class (the pid and the full command line), the Windows
1923
+ * binding for probeProcesses. The probe emits one `pid<TAB>commandLine` row
1924
+ * per process, which parseWindowsProcessTable turns into lines. A host without
1925
+ * the CIM service (a hung WMI) or a missing powershell degrades to an empty
1926
+ * table, which the detector reads as "no foreign engine" rather than a crash.
1927
+ */
1928
+ async function probeWindowsProcesses() {
1929
+ const result = await runCommand('powershell', [
1930
+ '-NoProfile',
1931
+ '-NonInteractive',
1932
+ '-Command',
1933
+ 'Get-CimInstance Win32_Process | ForEach-Object { "{0}`t{1}" -f $_.ProcessId, $_.CommandLine }',
1934
+ ]);
1935
+ if (!result.ok)
1936
+ return [];
1937
+ return parseWindowsProcessTable(result.stdout);
1938
+ }
1439
1939
  /**
1440
1940
  * readProcEnviron reads one process's environment out of its /proc environ
1441
1941
  * file (NUL-separated KEY=VALUE entries). Any failure (no /proc, the process
@@ -1519,12 +2019,14 @@ async function probeDocker() {
1519
2019
  return result.ok;
1520
2020
  }
1521
2021
  /**
1522
- * probePackageManager checks which of the four package-manager binaries exist
1523
- * on the host. `which` is a read-only probe, so each answer is a plain
1524
- * presence flag the pure detectPackageManager mapping turns into the winner.
2022
+ * probePackageManager checks whether a package-manager binary exists on the
2023
+ * host. The probe is `which` on Linux and macOS and `where` on Windows (the
2024
+ * OS's own presence probe), so each answer is a plain presence flag the pure
2025
+ * detectPackageManager mapping turns into the winner.
1525
2026
  */
1526
- async function probePackageManager(binary) {
1527
- const result = await runCommand('which', [binary]);
2027
+ async function probePackageManager(binary, os) {
2028
+ const probe = os === 'windows' ? 'where' : 'which';
2029
+ const result = await runCommand(probe, [binary]);
1528
2030
  return result.ok;
1529
2031
  }
1530
2032
  /**
@@ -1541,13 +2043,14 @@ export async function runHardwareProbes() {
1541
2043
  darwin: 'macos',
1542
2044
  };
1543
2045
  const os = osMap[platform()] ?? 'linux';
1544
- const [gpu, dockerAvailable, apt, dnf, pacman, brew] = await Promise.all([
2046
+ const [gpu, dockerAvailable, apt, dnf, pacman, brew, winget] = await Promise.all([
1545
2047
  probeGpu(),
1546
2048
  probeDocker(),
1547
- probePackageManager('apt-get'),
1548
- probePackageManager('dnf'),
1549
- probePackageManager('pacman'),
1550
- probePackageManager('brew'),
2049
+ probePackageManager('apt-get', os),
2050
+ probePackageManager('dnf', os),
2051
+ probePackageManager('pacman', os),
2052
+ probePackageManager('brew', os),
2053
+ probePackageManager('winget', os),
1551
2054
  ]);
1552
2055
  return {
1553
2056
  os,
@@ -1556,8 +2059,8 @@ export async function runHardwareProbes() {
1556
2059
  wsl2: os === 'linux' && release().toLowerCase().includes('wsl'),
1557
2060
  hostRamGiB: totalmem() / 2 ** 30,
1558
2061
  gpu,
1559
- packageManager: detectPackageManager({ os, apt, dnf, pacman, brew }) ?? null,
2062
+ packageManager: detectPackageManager({ os, apt, dnf, pacman, brew, winget }) ?? null,
1560
2063
  };
1561
2064
  }
1562
2065
  export default LocalInfer;
1563
- //# sourceMappingURL=index.js.map
2066
+ //# sourceMappingURL=index.js.map